From f630b483e86d703afa37f5eaa25c88b8c753c639 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 5 Jun 2025 15:49:39 -0400 Subject: [PATCH] Use glyph indices for font tracking in vector formats With libraqm, string layout produces glyph indices, not character codes, and font features may even produce different glyphs for the same character code (e.g., by picking a different Stylistic Set). Thus we cannot rely on character codes as unique items within a font, and must move toward glyph indices everywhere. --- .../next_api_changes/behavior/30335-ES.rst | 15 +++ lib/matplotlib/_mathtext.py | 15 ++- lib/matplotlib/_text_helpers.py | 16 +-- lib/matplotlib/backends/_backend_pdf_ps.py | 45 ++++--- lib/matplotlib/backends/backend_cairo.py | 10 +- lib/matplotlib/backends/backend_pdf.py | 111 +++++++++--------- lib/matplotlib/backends/backend_ps.py | 56 ++++----- lib/matplotlib/backends/backend_svg.py | 31 +++-- lib/matplotlib/dviread.pyi | 4 +- lib/matplotlib/tests/test_backend_pdf.py | 6 +- lib/matplotlib/tests/test_backend_svg.py | 2 +- lib/matplotlib/textpath.py | 53 ++++----- 12 files changed, 191 insertions(+), 173 deletions(-) create mode 100644 doc/api/next_api_changes/behavior/30335-ES.rst diff --git a/doc/api/next_api_changes/behavior/30335-ES.rst b/doc/api/next_api_changes/behavior/30335-ES.rst new file mode 100644 index 000000000000..26b059401e19 --- /dev/null +++ b/doc/api/next_api_changes/behavior/30335-ES.rst @@ -0,0 +1,15 @@ +``mathtext.VectorParse`` now includes glyph indices +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For a *path*-outputting `.MathTextParser`, in the return value of +`~.MathTextParser.parse`, (a `.VectorParse`), the *glyphs* field is now a list +containing tuples of: + +- font: `.FT2Font` +- fontsize: `float` +- character code: `int` +- glyph index: `int` +- x: `float` +- y: `float` + +Specifically, the glyph index was added after the character code. diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 234e5a238436..b85cdffd6d88 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -38,7 +38,7 @@ if T.TYPE_CHECKING: from collections.abc import Iterable - from .ft2font import CharacterCodeType, Glyph + from .ft2font import CharacterCodeType, Glyph, GlyphIndexType ParserElement.enable_packrat() @@ -87,7 +87,7 @@ class VectorParse(NamedTuple): width: float height: float depth: float - glyphs: list[tuple[FT2Font, float, CharacterCodeType, float, float]] + glyphs: list[tuple[FT2Font, float, CharacterCodeType, GlyphIndexType, float, float]] rects: list[tuple[float, float, float, float]] VectorParse.__module__ = "matplotlib.mathtext" @@ -132,7 +132,8 @@ def __init__(self, box: Box): def to_vector(self) -> VectorParse: w, h, d = map( np.ceil, [self.box.width, self.box.height, self.box.depth]) - gs = [(info.font, info.fontsize, info.num, ox, h - oy + info.offset) + gs = [(info.font, info.fontsize, info.num, info.glyph_index, + ox, h - oy + info.offset) for ox, oy, info in self.glyphs] rs = [(x1, h - y2, x2 - x1, y2 - y1) for x1, y1, x2, y2 in self.rects] @@ -215,6 +216,7 @@ class FontInfo(NamedTuple): postscript_name: str metrics: FontMetrics num: CharacterCodeType + glyph_index: GlyphIndexType glyph: Glyph offset: float @@ -375,7 +377,8 @@ def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float, dpi: float) -> FontInfo: font, num, slanted = self._get_glyph(fontname, font_class, sym) font.set_size(fontsize, dpi) - glyph = font.load_char(num, flags=self.load_glyph_flags) + glyph_index = font.get_char_index(num) + glyph = font.load_glyph(glyph_index, flags=self.load_glyph_flags) xmin, ymin, xmax, ymax = (val / 64 for val in glyph.bbox) offset = self._get_offset(font, glyph, fontsize, dpi) @@ -398,6 +401,7 @@ def _get_info(self, fontname: str, font_class: str, sym: str, fontsize: float, postscript_name=font.postscript_name, metrics=metrics, num=num, + glyph_index=glyph_index, glyph=glyph, offset=offset ) @@ -427,8 +431,7 @@ def get_kern(self, font1: str, fontclass1: str, sym1: str, fontsize1: float, info1 = self._get_info(font1, fontclass1, sym1, fontsize1, dpi) info2 = self._get_info(font2, fontclass2, sym2, fontsize2, dpi) font = info1.font - return font.get_kerning(font.get_char_index(info1.num), - font.get_char_index(info2.num), + return font.get_kerning(info1.glyph_index, info2.glyph_index, Kerning.DEFAULT) / 64 return super().get_kern(font1, fontclass1, sym1, fontsize1, font2, fontclass2, sym2, fontsize2, dpi) diff --git a/lib/matplotlib/_text_helpers.py b/lib/matplotlib/_text_helpers.py index a874c8f4bf81..b9471c2c7e39 100644 --- a/lib/matplotlib/_text_helpers.py +++ b/lib/matplotlib/_text_helpers.py @@ -14,7 +14,7 @@ class LayoutItem: ft_object: FT2Font char: str - glyph_idx: GlyphIndexType + glyph_index: GlyphIndexType x: float prev_kern: float @@ -47,19 +47,19 @@ def layout(string, font, *, kern_mode=Kerning.DEFAULT): LayoutItem """ x = 0 - prev_glyph_idx = None + prev_glyph_index = None char_to_font = font._get_fontmap(string) base_font = font for char in string: # This has done the fallback logic font = char_to_font.get(char, base_font) - glyph_idx = font.get_char_index(ord(char)) + glyph_index = font.get_char_index(ord(char)) kern = ( - base_font.get_kerning(prev_glyph_idx, glyph_idx, kern_mode) / 64 - if prev_glyph_idx is not None else 0. + 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_idx, flags=LoadFlags.NO_HINTING) - yield LayoutItem(font, char, glyph_idx, 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_idx = glyph_idx + prev_glyph_index = glyph_index diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index a2a878d54156..75f0a05ae0dc 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -2,9 +2,12 @@ Common functionality between the PDF and PS backends. """ +from __future__ import annotations + from io import BytesIO import functools import logging +import typing from fontTools import subset @@ -14,24 +17,29 @@ from ..backend_bases import RendererBase +if typing.TYPE_CHECKING: + from .ft2font import FT2Font, GlyphIndexType + from fontTools.ttLib import TTFont + + @functools.lru_cache(50) def _cached_get_afm_from_fname(fname): with open(fname, "rb") as fh: return AFM(fh) -def get_glyphs_subset(fontfile, characters): +def get_glyphs_subset(fontfile: str, glyphs: set[GlyphIndexType]) -> TTFont: """ - Subset a TTF font + Subset a TTF font. - Reads the named fontfile and restricts the font to the characters. + Reads the named fontfile and restricts the font to the glyphs. Parameters ---------- fontfile : str Path to the font file - characters : str - Continuous set of characters to include in subset + glyphs : set[GlyphIndexType] + Set of glyph indices to include in subset. Returns ------- @@ -39,8 +47,8 @@ def get_glyphs_subset(fontfile, characters): An open font object representing the subset, which needs to be closed by the caller. """ - - options = subset.Options(glyph_names=True, recommended_glyphs=True) + options = subset.Options(glyph_names=True, recommended_glyphs=True, + retain_gids=True) # Prevent subsetting extra tables. options.drop_tables += [ @@ -71,7 +79,7 @@ def get_glyphs_subset(fontfile, characters): font = subset.load_font(fontfile, options) subsetter = subset.Subsetter(options=options) - subsetter.populate(text=characters) + subsetter.populate(gids=glyphs) subsetter.subset(font) return font @@ -97,24 +105,25 @@ def font_as_file(font): class CharacterTracker: """ - Helper for font subsetting by the pdf and ps backends. + Helper for font subsetting by the PDF and PS backends. - Maintains a mapping of font paths to the set of character codepoints that - are being used from that font. + Maintains a mapping of font paths to the set of glyphs that are being used from that + font. """ - def __init__(self): - self.used = {} + def __init__(self) -> None: + self.used: dict[str, set[GlyphIndexType]] = {} - def track(self, font, s): + def track(self, font: FT2Font, s: str) -> None: """Record that string *s* is being typeset using font *font*.""" char_to_font = font._get_fontmap(s) for _c, _f in char_to_font.items(): - self.used.setdefault(_f.fname, set()).add(ord(_c)) + glyph_index = _f.get_char_index(ord(_c)) + self.used.setdefault(_f.fname, set()).add(glyph_index) - def track_glyph(self, font, glyph): - """Record that codepoint *glyph* is being typeset using font *font*.""" - self.used.setdefault(font.fname, set()).add(glyph) + def track_glyph(self, font: FT2Font, glyph_index: GlyphIndexType) -> None: + """Record that glyph index *glyph_index* is being typeset using font *font*.""" + self.used.setdefault(font.fname, set()).add(glyph_index) class RendererPDFPSBase(RendererBase): diff --git a/lib/matplotlib/backends/backend_cairo.py b/lib/matplotlib/backends/backend_cairo.py index 7409cd35b394..e20ec3fc2313 100644 --- a/lib/matplotlib/backends/backend_cairo.py +++ b/lib/matplotlib/backends/backend_cairo.py @@ -8,6 +8,7 @@ import functools import gzip +import itertools import math import numpy as np @@ -248,13 +249,12 @@ def _draw_mathtext(self, gc, x, y, s, prop, angle): if angle: ctx.rotate(np.deg2rad(-angle)) - for font, fontsize, idx, ox, oy in glyphs: + for (font, fontsize), font_glyphs in itertools.groupby( + glyphs, key=lambda info: (info[0], info[1])): ctx.new_path() - ctx.move_to(ox, -oy) - ctx.select_font_face( - *_cairo_font_args_from_font_prop(ttfFontProperty(font))) + ctx.select_font_face(*_cairo_font_args_from_font_prop(ttfFontProperty(font))) ctx.set_font_size(self.points_to_pixels(fontsize)) - ctx.show_text(chr(idx)) + ctx.show_glyphs([(idx, ox, -oy) for _, _, idx, ox, oy in font_glyphs]) for ox, oy, w, h in rects: ctx.new_path() diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 6682deffb00e..7f1905f96f12 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -19,6 +19,7 @@ import sys import time import types +import typing import warnings import zlib @@ -35,7 +36,8 @@ 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, GlyphIndexType, Kerning, LoadFlags, StyleFlags) from matplotlib.transforms import Affine2D, BboxBase from matplotlib.path import Path from matplotlib.dates import UTC @@ -611,12 +613,12 @@ def _flush(self): self.compressobj = None -def _get_pdf_charprocs(font_path, glyph_ids): +def _get_pdf_charprocs(font_path, glyph_indices): font = get_font(font_path, hinting_factor=1) conv = 1000 / font.units_per_EM # Conversion to PS units (1/1000's). procs = {} - for glyph_id in glyph_ids: - g = font.load_glyph(glyph_id, LoadFlags.NO_SCALE) + for glyph_index in glyph_indices: + g = font.load_glyph(glyph_index, LoadFlags.NO_SCALE) d1 = [ round(g.horiAdvance * conv), 0, # Round bbox corners *outwards*, so that they indeed bound the glyph. @@ -625,7 +627,7 @@ def _get_pdf_charprocs(font_path, glyph_ids): ] v, c = font.get_path() v = (v * 64 * conv).round() # Back to TrueType's internal units (1/64's). - procs[font.get_glyph_name(glyph_id)] = ( + procs[font.get_glyph_name(glyph_index)] = ( " ".join(map(str, d1)).encode("ascii") + b" d1\n" + _path.convert_to_string( Path(v, c), None, None, False, None, 0, @@ -960,9 +962,9 @@ def writeFonts(self): else: # a normal TrueType font _log.debug('Writing TrueType font.') - chars = self._character_tracker.used.get(filename) - if chars: - fonts[Fx] = self.embedTTF(filename, chars) + glyphs = self._character_tracker.used.get(filename) + if glyphs: + fonts[Fx] = self.embedTTF(filename, glyphs) self.writeObject(self.fontObject, fonts) def _write_afm_font(self, filename): @@ -1136,9 +1138,8 @@ def _get_xobject_glyph_name(self, filename, glyph_name): end end""" - def embedTTF(self, filename, characters): + def embedTTF(self, filename, glyphs): """Embed the TTF font from the named file into the document.""" - font = get_font(filename) fonttype = mpl.rcParams['pdf.fonttype'] @@ -1153,7 +1154,7 @@ def cvt(length, upe=font.units_per_EM, nearest=True): else: return math.ceil(value) - def embedTTFType3(font, characters, descriptor): + def embedTTFType3(font, glyphs, descriptor): """The Type 3-specific part of embedding a Truetype font""" widthsObject = self.reserveObject('font widths') fontdescObject = self.reserveObject('font descriptor') @@ -1198,15 +1199,13 @@ def get_char_width(charcode): # Make the "Differences" array, sort the ccodes < 255 from # the multi-byte ccodes, and build the whole set of glyph ids # that we need from this font. - glyph_ids = [] differences = [] multi_byte_chars = set() - for c in characters: - ccode = c - gind = font.get_char_index(ccode) - glyph_ids.append(gind) + charmap = {gind: ccode for ccode, gind in font.get_charmap().items()} + for gind in glyphs: glyph_name = font.get_glyph_name(gind) - if ccode <= 255: + ccode = charmap.get(gind) + if ccode is not None and ccode <= 255: differences.append((ccode, glyph_name)) else: multi_byte_chars.add(glyph_name) @@ -1220,7 +1219,7 @@ def get_char_width(charcode): last_c = c # Make the charprocs array. - rawcharprocs = _get_pdf_charprocs(filename, glyph_ids) + rawcharprocs = _get_pdf_charprocs(filename, glyphs) charprocs = {} for charname in sorted(rawcharprocs): stream = rawcharprocs[charname] @@ -1257,7 +1256,7 @@ def get_char_width(charcode): return fontdictObject - def embedTTFType42(font, characters, descriptor): + def embedTTFType42(font, glyphs, descriptor): """The Type 42-specific part of embedding a Truetype font""" fontdescObject = self.reserveObject('font descriptor') cidFontDictObject = self.reserveObject('CID font dictionary') @@ -1267,9 +1266,8 @@ def embedTTFType42(font, characters, descriptor): wObject = self.reserveObject('Type 0 widths') toUnicodeMapObject = self.reserveObject('ToUnicode map') - subset_str = "".join(chr(c) for c in characters) - _log.debug("SUBSET %s characters: %s", filename, subset_str) - with _backend_pdf_ps.get_glyphs_subset(filename, subset_str) as subset: + _log.debug("SUBSET %s characters: %s", filename, glyphs) + with _backend_pdf_ps.get_glyphs_subset(filename, glyphs) as subset: fontdata = _backend_pdf_ps.font_as_file(subset) _log.debug( "SUBSET %s %d -> %d", filename, @@ -1317,11 +1315,11 @@ def embedTTFType42(font, characters, descriptor): cid_to_gid_map = ['\0'] * 65536 widths = [] max_ccode = 0 - for c in characters: - ccode = c - gind = font.get_char_index(ccode) - glyph = font.load_char(ccode, - flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING) + charmap = {gind: ccode for ccode, gind in font.get_charmap().items()} + for gind in glyphs: + glyph = font.load_glyph(gind, + flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING) + ccode = charmap[gind] widths.append((ccode, cvt(glyph.horiAdvance))) if ccode < 65536: cid_to_gid_map[ccode] = chr(gind) @@ -1359,14 +1357,13 @@ def embedTTFType42(font, characters, descriptor): (len(unicode_groups), b"\n".join(unicode_bfrange))) # Add XObjects for unsupported chars - glyph_ids = [] - for ccode in characters: - if not _font_supports_glyph(fonttype, ccode): - gind = full_font.get_char_index(ccode) - glyph_ids.append(gind) + glyph_indices = [ + glyph_index for glyph_index in glyphs + if not _font_supports_glyph(fonttype, charmap[glyph_index]) + ] bbox = [cvt(x, nearest=False) for x in full_font.bbox] - rawcharprocs = _get_pdf_charprocs(filename, glyph_ids) + rawcharprocs = _get_pdf_charprocs(filename, glyph_indices) for charname in sorted(rawcharprocs): stream = rawcharprocs[charname] charprocDict = {'Type': Name('XObject'), @@ -1448,9 +1445,9 @@ def embedTTFType42(font, characters, descriptor): } if fonttype == 3: - return embedTTFType3(font, characters, descriptor) + return embedTTFType3(font, glyphs, descriptor) elif fonttype == 42: - return embedTTFType42(font, characters, descriptor) + return embedTTFType42(font, glyphs, descriptor) def alphaState(self, alpha): """Return name of an ExtGState that sets alpha to the given value.""" @@ -2214,13 +2211,13 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): unsupported_chars = [] self.file.output(Op.begin_text) - for font, fontsize, num, ox, oy in glyphs: - self.file._character_tracker.track_glyph(font, num) + for font, fontsize, ccode, glyph_index, ox, oy in glyphs: + self.file._character_tracker.track_glyph(font, glyph_index) fontname = font.fname - if not _font_supports_glyph(fonttype, num): + if not _font_supports_glyph(fonttype, ccode): # Unsupported chars (i.e. multibyte in Type 3 or beyond BMP in # Type 42) must be emitted separately (below). - unsupported_chars.append((font, fontsize, ox, oy, num)) + unsupported_chars.append((font, fontsize, ox, oy, glyph_index)) else: self._setup_textpos(ox, oy, 0, oldx, oldy) oldx, oldy = ox, oy @@ -2228,13 +2225,12 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): self.file.output(self.file.fontName(fontname), fontsize, Op.selectfont) prev_font = fontname, fontsize - self.file.output(self.encode_string(chr(num), fonttype), + self.file.output(self.encode_string(chr(ccode), fonttype), Op.show) self.file.output(Op.end_text) - for font, fontsize, ox, oy, num in unsupported_chars: - self._draw_xobject_glyph( - font, fontsize, font.get_char_index(num), ox, oy) + for font, fontsize, ox, oy, glyph_index in unsupported_chars: + self._draw_xobject_glyph(font, fontsize, glyph_index, ox, oy) # Draw any horizontal lines in the math layout for ox, oy, width, height in rects: @@ -2266,13 +2262,17 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): # one single-character string, but later it may have longer # strings interspersed with kern amounts. oldfont, seq = None, [] - for x1, y1, dvifont, glyph, width in page.text: - if dvifont != oldfont: - pdfname = self.file.dviFontName(dvifont) - seq += [['font', pdfname, dvifont.size]] - oldfont = dvifont - seq += [['text', x1, y1, [bytes([glyph])], x1+width]] - self.file._character_tracker.track(dvifont, chr(glyph)) + for text in page.text: + if text.font != oldfont: + pdfname = self.file.dviFontName(text.font) + seq += [['font', pdfname, text.font.size]] + oldfont = text.font + seq += [['text', text.x, text.y, [bytes([text.glyph])], text.x+text.width]] + # TODO: This should use glyph indices, not character codes, but will be + # fixed soon. + self.file._character_tracker.track_glyph(text.font, + typing.cast('GlyphIndexType', + text.glyph)) # Find consecutive text strings with constant y coordinate and # combine into a sequence of strings and kerns, or just one @@ -2401,7 +2401,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): singlebyte_chunks[-1][2].append(item.char) prev_was_multibyte = False else: - multibyte_glyphs.append((item.ft_object, item.x, item.glyph_idx)) + multibyte_glyphs.append((item.ft_object, item.x, item.glyph_index)) prev_was_multibyte = True # Do the rotation and global translation as a single matrix # concatenation up front @@ -2411,7 +2411,6 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): -math.sin(a), math.cos(a), x, y, Op.concat_matrix) # Emit all the 1-byte characters in a BT/ET group. - self.file.output(Op.begin_text) prev_start_x = 0 for ft_object, start_x, kerns_or_chars in singlebyte_chunks: @@ -2428,15 +2427,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): prev_start_x = start_x self.file.output(Op.end_text) # Then emit all the multibyte characters, one at a time. - for ft_object, start_x, glyph_idx in multibyte_glyphs: + for ft_object, start_x, glyph_index in multibyte_glyphs: self._draw_xobject_glyph( - ft_object, fontsize, glyph_idx, start_x, 0 + ft_object, fontsize, glyph_index, start_x, 0 ) self.file.output(Op.grestore) - def _draw_xobject_glyph(self, font, fontsize, glyph_idx, x, y): + def _draw_xobject_glyph(self, font, fontsize, glyph_index, x, y): """Draw a multibyte character from a Type 3 font as an XObject.""" - glyph_name = font.get_glyph_name(glyph_idx) + glyph_name = font.get_glyph_name(glyph_index) name = self.file._get_xobject_glyph_name(font.fname, glyph_name) self.file.output( Op.gsave, diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 368564a1518d..060a40c08e8b 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -88,16 +88,16 @@ def _move_path_to_path_or_stream(src, dst): shutil.move(src, dst, copy_function=shutil.copyfile) -def _font_to_ps_type3(font_path, chars): +def _font_to_ps_type3(font_path, glyph_indices): """ - Subset *chars* from the font at *font_path* into a Type 3 font. + Subset *glyphs_indices* from the font at *font_path* into a Type 3 font. Parameters ---------- font_path : path-like Path to the font to be subsetted. - chars : str - The characters to include in the subsetted font. + glyph_indices : set[int] + The glyphs to include in the subsetted font. Returns ------- @@ -106,7 +106,6 @@ def _font_to_ps_type3(font_path, chars): verbatim into a PostScript file. """ font = get_font(font_path, hinting_factor=1) - glyph_ids = [font.get_char_index(c) for c in chars] preamble = """\ %!PS-Adobe-3.0 Resource-Font @@ -123,9 +122,9 @@ def _font_to_ps_type3(font_path, chars): """.format(font_name=font.postscript_name, inv_units_per_em=1 / font.units_per_EM, bbox=" ".join(map(str, font.bbox)), - encoding=" ".join(f"/{font.get_glyph_name(glyph_id)}" - for glyph_id in glyph_ids), - num_glyphs=len(glyph_ids) + 1) + encoding=" ".join(f"/{font.get_glyph_name(glyph_index)}" + for glyph_index in glyph_indices), + num_glyphs=len(glyph_indices) + 1) postamble = """ end readonly def @@ -146,12 +145,12 @@ def _font_to_ps_type3(font_path, chars): """ entries = [] - for glyph_id in glyph_ids: - g = font.load_glyph(glyph_id, LoadFlags.NO_SCALE) + for glyph_index in glyph_indices: + g = font.load_glyph(glyph_index, LoadFlags.NO_SCALE) v, c = font.get_path() entries.append( "/%(name)s{%(bbox)s sc\n" % { - "name": font.get_glyph_name(glyph_id), + "name": font.get_glyph_name(glyph_index), "bbox": " ".join(map(str, [g.horiAdvance, 0, *g.bbox])), } + _path.convert_to_string( @@ -169,21 +168,20 @@ def _font_to_ps_type3(font_path, chars): return preamble + "\n".join(entries) + postamble -def _font_to_ps_type42(font_path, chars, fh): +def _font_to_ps_type42(font_path, glyph_indices, fh): """ - Subset *chars* from the font at *font_path* into a Type 42 font at *fh*. + Subset *glyph_indices* from the font at *font_path* into a Type 42 font at *fh*. Parameters ---------- font_path : path-like Path to the font to be subsetted. - chars : str - The characters to include in the subsetted font. + glyph_indices : set[int] + The glyphs to include in the subsetted font. fh : file-like Where to write the font. """ - subset_str = ''.join(chr(c) for c in chars) - _log.debug("SUBSET %s characters: %s", font_path, subset_str) + _log.debug("SUBSET %s characters: %s", font_path, glyph_indices) try: kw = {} # fix this once we support loading more fonts from a collection @@ -191,7 +189,7 @@ def _font_to_ps_type42(font_path, chars, fh): if font_path.endswith('.ttc'): kw['fontNumber'] = 0 with (fontTools.ttLib.TTFont(font_path, **kw) as font, - _backend_pdf_ps.get_glyphs_subset(font_path, subset_str) as subset): + _backend_pdf_ps.get_glyphs_subset(font_path, glyph_indices) as subset): fontdata = _backend_pdf_ps.font_as_file(subset).getvalue() _log.debug( "SUBSET %s %d -> %d", font_path, os.stat(font_path).st_size, @@ -775,8 +773,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): if mpl.rcParams['ps.useafm']: font = self._get_font_afm(prop) - ps_name = (font.postscript_name.encode("ascii", "replace") - .decode("ascii")) + ps_name = font.postscript_name.encode("ascii", "replace").decode("ascii") scale = 0.001 * prop.get_size_in_points() thisx = 0 last_name = '' # kerns returns 0 for ''. @@ -799,7 +796,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): for item in _text_helpers.layout(s, font): ps_name = (item.ft_object.postscript_name .encode("ascii", "replace").decode("ascii")) - glyph_name = item.ft_object.get_glyph_name(item.glyph_idx) + glyph_name = item.ft_object.get_glyph_name(item.glyph_index) stream.append((ps_name, item.x, glyph_name)) self.set_color(*gc.get_rgb()) @@ -828,13 +825,13 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): f"{x:g} {y:g} translate\n" f"{angle:g} rotate\n") lastfont = None - for font, fontsize, num, ox, oy in glyphs: - self._character_tracker.track_glyph(font, num) + for font, fontsize, ccode, glyph_index, ox, oy in glyphs: + self._character_tracker.track_glyph(font, glyph_index) if (font.postscript_name, fontsize) != lastfont: lastfont = font.postscript_name, fontsize self._pswriter.write( f"/{font.postscript_name} {fontsize} selectfont\n") - glyph_name = font.get_glyph_name(font.get_char_index(num)) + glyph_name = font.get_glyph_name(glyph_index) self._pswriter.write( f"{ox:g} {oy:g} moveto\n" f"/{glyph_name} glyphshow\n") @@ -1072,19 +1069,18 @@ def print_figure_impl(fh): print("mpldict begin", file=fh) print("\n".join(_psDefs), file=fh) if not mpl.rcParams['ps.useafm']: - for font_path, chars \ - in ps_renderer._character_tracker.used.items(): - if not chars: + for font_path, glyphs in ps_renderer._character_tracker.used.items(): + if not glyphs: continue fonttype = mpl.rcParams['ps.fonttype'] # Can't use more than 255 chars from a single Type 3 font. - if len(chars) > 255: + if len(glyphs) > 255: fonttype = 42 fh.flush() if fonttype == 3: - fh.write(_font_to_ps_type3(font_path, chars)) + fh.write(_font_to_ps_type3(font_path, glyphs)) else: # Type 42 only. - _font_to_ps_type42(font_path, chars, fh) + _font_to_ps_type42(font_path, glyphs, fh) print("end", file=fh) print("%%EndProlog", file=fh) diff --git a/lib/matplotlib/backends/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index 0cb6430ec823..7b94a2b9ba2b 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -1023,19 +1023,19 @@ def _update_glyph_map_defs(self, glyph_map_new): writer = self.writer if glyph_map_new: writer.start('defs') - for char_id, (vertices, codes) in glyph_map_new.items(): - char_id = self._adjust_char_id(char_id) + for glyph_repr, (vertices, codes) in glyph_map_new.items(): + glyph_repr = self._adjust_glyph_repr(glyph_repr) # x64 to go back to FreeType's internal (integral) units. path_data = self._convert_path( Path(vertices * 64, codes), simplify=False) writer.element( - 'path', id=char_id, d=path_data, + 'path', id=glyph_repr, d=path_data, transform=_generate_transform([('scale', (1 / 64,))])) writer.end('defs') self._glyph_map.update(glyph_map_new) - def _adjust_char_id(self, char_id): - return char_id.replace("%20", "_") + def _adjust_glyph_repr(self, glyph_repr): + return glyph_repr.replace("%20", "_") def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): # docstring inherited @@ -1067,19 +1067,18 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): if not ismath: font = text2path._get_font(prop) - _glyphs = text2path.get_glyphs_with_font( + glyph_info, glyph_map_new, rects = text2path.get_glyphs_with_font( font, s, glyph_map=glyph_map, return_new_glyphs_only=True) - glyph_info, glyph_map_new, rects = _glyphs self._update_glyph_map_defs(glyph_map_new) - for glyph_id, xposition, yposition, scale in glyph_info: + for glyph_repr, xposition, yposition, scale in glyph_info: writer.element( 'use', transform=_generate_transform([ ('translate', (xposition, yposition)), ('scale', (scale,)), ]), - attrib={'xlink:href': f'#{glyph_id}'}) + attrib={'xlink:href': f'#{glyph_repr}'}) else: if ismath == "TeX": @@ -1091,15 +1090,15 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None): glyph_info, glyph_map_new, rects = _glyphs self._update_glyph_map_defs(glyph_map_new) - for char_id, xposition, yposition, scale in glyph_info: - char_id = self._adjust_char_id(char_id) + for glyph_repr, xposition, yposition, scale in glyph_info: + glyph_repr = self._adjust_glyph_repr(glyph_repr) writer.element( 'use', transform=_generate_transform([ ('translate', (xposition, yposition)), ('scale', (scale,)), ]), - attrib={'xlink:href': f'#{char_id}'}) + attrib={'xlink:href': f'#{glyph_repr}'}) for verts, codes in rects: path = Path(verts, codes) @@ -1223,7 +1222,7 @@ def _get_all_quoted_names(prop): # Sort the characters by font, and output one tspan for each. spans = {} - for font, fontsize, thetext, new_x, new_y in glyphs: + for font, fontsize, ccode, glyph_index, new_x, new_y in glyphs: entry = fm.ttfFontProperty(font) font_style = {} # Separate font style in its separate attributes @@ -1238,9 +1237,9 @@ def _get_all_quoted_names(prop): if entry.stretch != 'normal': font_style['font-stretch'] = entry.stretch style = _generate_css({**font_style, **color_style}) - if thetext == 32: - thetext = 0xa0 # non-breaking space - spans.setdefault(style, []).append((new_x, -new_y, thetext)) + if ccode == 32: + ccode = 0xa0 # non-breaking space + spans.setdefault(style, []).append((new_x, -new_y, ccode)) for style, chars in spans.items(): chars.sort() # Sort by increasing x position diff --git a/lib/matplotlib/dviread.pyi b/lib/matplotlib/dviread.pyi index 6ddc463295a9..c1e911a88355 100644 --- a/lib/matplotlib/dviread.pyi +++ b/lib/matplotlib/dviread.pyi @@ -8,7 +8,7 @@ from collections.abc import Generator from typing import NamedTuple from typing import Self -from .ft2font import GlyphIndexType +from .ft2font import CharacterCodeType, GlyphIndexType class _dvistate(Enum): @@ -35,7 +35,7 @@ class Text(NamedTuple): x: int y: int font: DviFont - glyph: int + glyph: CharacterCodeType width: int @property def font_path(self) -> Path: ... diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index f126fb543e78..2dc22fd9170e 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -361,13 +361,13 @@ def test_glyphs_subset(): # non-subsetted FT2Font nosubfont = FT2Font(fpath) nosubfont.set_text(chars) + nosubcmap = nosubfont.get_charmap() # subsetted FT2Font - with get_glyphs_subset(fpath, chars) as subset: + glyph_indices = {nosubcmap[ord(c)] for c in chars} + with get_glyphs_subset(fpath, glyph_indices) as subset: subfont = FT2Font(font_as_file(subset)) subfont.set_text(chars) - - nosubcmap = nosubfont.get_charmap() subcmap = subfont.get_charmap() # all unique chars must be available in subsetted font diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index 2c64b7c24b3e..e865dbbe92da 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -216,7 +216,7 @@ def test_unicode_won(): tree = xml.etree.ElementTree.fromstring(buf) ns = 'http://www.w3.org/2000/svg' - won_id = 'SFSS1728-8e' + won_id = 'SFSS1728-232' assert len(tree.findall(f'.//{{{ns}}}path[@d][@id="{won_id}"]')) == 1 assert f'#{won_id}' in tree.find(f'.//{{{ns}}}use').attrib.values() diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index b57597ded363..626568ba134e 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -39,11 +39,9 @@ def _get_font(self, prop): def _get_hinting_flag(self): return LoadFlags.NO_HINTING - def _get_char_id(self, font, ccode): - """ - Return a unique id for the given font and character-code set. - """ - return urllib.parse.quote(f"{font.postscript_name}-{ccode:x}") + def _get_glyph_repr(self, font, glyph): + """Return a unique id for the given font and glyph index.""" + return urllib.parse.quote(f"{font.postscript_name}-{glyph:x}") def get_text_width_height_descent(self, s, prop, ismath): fontsize = prop.get_size_in_points() @@ -114,8 +112,8 @@ def get_text_path(self, prop, s, ismath=False): glyph_info, glyph_map, rects = self.get_glyphs_mathtext(prop, s) verts, codes = [], [] - for glyph_id, xposition, yposition, scale in glyph_info: - verts1, codes1 = glyph_map[glyph_id] + for glyph_repr, xposition, yposition, scale in glyph_info: + verts1, codes1 = glyph_map[glyph_repr] verts.extend(verts1 * scale + [xposition, yposition]) codes.extend(codes1) for verts1, codes1 in rects: @@ -144,20 +142,20 @@ def get_glyphs_with_font(self, font, s, glyph_map=None, glyph_map_new = glyph_map xpositions = [] - glyph_ids = [] + glyph_reprs = [] for item in _text_helpers.layout(s, font): - char_id = self._get_char_id(item.ft_object, ord(item.char)) - glyph_ids.append(char_id) + glyph_repr = self._get_glyph_repr(item.ft_object, item.glyph_index) + glyph_reprs.append(glyph_repr) xpositions.append(item.x) - if char_id not in glyph_map: - glyph_map_new[char_id] = item.ft_object.get_path() + 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 = [] - return (list(zip(glyph_ids, xpositions, ypositions, sizes)), + return (list(zip(glyph_reprs, xpositions, ypositions, sizes)), glyph_map_new, rects) def get_glyphs_mathtext(self, prop, s, glyph_map=None, @@ -182,20 +180,20 @@ def get_glyphs_mathtext(self, prop, s, glyph_map=None, xpositions = [] ypositions = [] - glyph_ids = [] + glyph_reprs = [] sizes = [] - for font, fontsize, ccode, ox, oy in glyphs: - char_id = self._get_char_id(font, ccode) - if char_id not in glyph_map: + for font, fontsize, ccode, glyph_index, ox, oy in glyphs: + glyph_repr = self._get_glyph_repr(font, glyph_index) + if glyph_repr not in glyph_map: font.clear() font.set_size(self.FONT_SCALE, self.DPI) - font.load_char(ccode, flags=LoadFlags.NO_HINTING) - glyph_map_new[char_id] = font.get_path() + font.load_glyph(glyph_index, flags=LoadFlags.NO_HINTING) + glyph_map_new[glyph_repr] = font.get_path() xpositions.append(ox) ypositions.append(oy) - glyph_ids.append(char_id) + glyph_reprs.append(glyph_repr) size = fontsize / self.FONT_SCALE sizes.append(size) @@ -208,7 +206,7 @@ def get_glyphs_mathtext(self, prop, s, glyph_map=None, Path.CLOSEPOLY] myrects.append((vert1, code1)) - return (list(zip(glyph_ids, xpositions, ypositions, sizes)), + return (list(zip(glyph_reprs, xpositions, ypositions, sizes)), glyph_map_new, myrects) def get_glyphs_tex(self, prop, s, glyph_map=None, @@ -228,21 +226,20 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, else: glyph_map_new = glyph_map - glyph_ids, xpositions, ypositions, sizes = [], [], [], [] + glyph_reprs, xpositions, ypositions, sizes = [], [], [], [] # Gather font information and do some setup for combining # characters into strings. - t1_encodings = {} for text in page.text: font = get_font(text.font_path) - char_id = self._get_char_id(font, text.glyph) - if char_id not in glyph_map: + glyph_repr = self._get_glyph_repr(font, text.index) + if glyph_repr not in glyph_map: font.clear() font.set_size(self.FONT_SCALE, self.DPI) font.load_glyph(text.index, flags=LoadFlags.TARGET_LIGHT) - glyph_map_new[char_id] = font.get_path() + glyph_map_new[glyph_repr] = font.get_path() - glyph_ids.append(char_id) + glyph_reprs.append(glyph_repr) xpositions.append(text.x) ypositions.append(text.y) sizes.append(text.font_size / self.FONT_SCALE) @@ -257,7 +254,7 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, Path.CLOSEPOLY] myrects.append((vert1, code1)) - return (list(zip(glyph_ids, xpositions, ypositions, sizes)), + return (list(zip(glyph_reprs, xpositions, ypositions, sizes)), glyph_map_new, myrects)