From b70fb888657ce566950a5f83b3e6e88b4c44404d Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 3 Sep 2025 05:21:04 -0400 Subject: [PATCH 1/4] pdf: Improve text with characters outside embedded font limits For character codes outside the embedded font limits (256 for type 3 and 65536 for type 42), we output them as XObjects instead of using text commands. But there is nothing in the PDF spec that requires any specific encoding like this. Since we now support subsetting all fonts before embedding, split each font into groups based on the maximum character code (e.g., 256-entry groups for type 3), then switch text strings to a different font subset and re-map character codes to it when necessary. This means all text is true text (albeit with some strange encoding), and we no longer need any XObjects for glyphs. For users of non-English text, this means it will become selectable and copyable again. Fixes #21797 --- .../deprecations/30512-ES.rst | 3 + lib/matplotlib/backends/backend_pdf.py | 245 +++++------------- 2 files changed, 71 insertions(+), 177 deletions(-) create mode 100644 doc/api/next_api_changes/deprecations/30512-ES.rst diff --git a/doc/api/next_api_changes/deprecations/30512-ES.rst b/doc/api/next_api_changes/deprecations/30512-ES.rst new file mode 100644 index 000000000000..f235964c5502 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30512-ES.rst @@ -0,0 +1,3 @@ +``PdfFile.multi_byte_charprocs`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +... is deprecated with no replacement. diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index ebbc70eb68c8..06fa09793553 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -19,7 +19,6 @@ import sys import time import types -import warnings import zlib import numpy as np @@ -369,19 +368,10 @@ def pdfRepr(obj): "objects") -def _font_supports_glyph(fonttype, glyph): - """ - Returns True if the font is able to provide codepoint *glyph* in a PDF. - - For a Type 3 font, this method returns True only for single-byte - characters. For Type 42 fonts this method return True if the character is - from the Basic Multilingual Plane. - """ - if fonttype == 3: - return glyph <= 255 - if fonttype == 42: - return glyph <= 65535 - raise NotImplementedError() +_FONT_MAX_GLYPH = { + 3: 256, + 42: 65536, +} class Reference: @@ -700,7 +690,8 @@ def __init__(self, filename, metadata=None): self._internal_font_seq = (Name(f'F{i}') for i in itertools.count(1)) self._fontNames = {} # maps filenames to internal font names self._dviFontInfo = {} # maps pdf names to dvifonts - self._character_tracker = _backend_pdf_ps.CharacterTracker() + self._character_tracker = _backend_pdf_ps.CharacterTracker( + _FONT_MAX_GLYPH.get(mpl.rcParams['pdf.fonttype'], 0)) self.alphaStates = {} # maps alpha values to graphics state objects self._alpha_state_seq = (Name(f'A{i}') for i in itertools.count(1)) @@ -715,7 +706,6 @@ def __init__(self, filename, metadata=None): self._image_seq = (Name(f'I{i}') for i in itertools.count(1)) self.markers = {} - self.multi_byte_charprocs = {} self.paths = [] @@ -742,6 +732,7 @@ def __init__(self, filename, metadata=None): self.writeObject(self.resourceObject, resources) fontNames = _api.deprecated("3.11")(property(lambda self: self._fontNames)) + multi_byte_charprocs = _api.deprecated("3.11")(property(lambda _: {})) type1Descriptors = _api.deprecated("3.11")(property(lambda _: {})) @_api.deprecated("3.11") @@ -829,7 +820,7 @@ def toStr(n, base): @staticmethod def _get_subsetted_psname(ps_name, charmap): - return PdfFile._get_subset_prefix(frozenset(charmap.keys())) + ps_name + return PdfFile._get_subset_prefix(frozenset(charmap.values())) + ps_name def finalize(self): """Write out the various deferred objects and the pdf end matter.""" @@ -845,8 +836,6 @@ def finalize(self): name: ob for image, name, ob in self._images.values()} for tup in self.markers.values(): xobjects[tup[0]] = tup[1] - for name, value in self.multi_byte_charprocs.items(): - xobjects[name] = value for name, path, trans, ob, join, cap, padding, filled, stroked \ in self.paths: xobjects[name] = ob @@ -903,7 +892,7 @@ def _write_annotations(self): for annotsObject, annotations in self._annotations: self.writeObject(annotsObject, annotations) - def fontName(self, fontprop): + def fontName(self, fontprop, subset=0): """ Select a font based on fontprop and return a name suitable for ``Op.selectfont``. If fontprop is a string, it will be interpreted @@ -920,13 +909,13 @@ def fontName(self, fontprop): filenames = _fontManager._find_fonts_by_props(fontprop) first_Fx = None for fname in filenames: - Fx = self._fontNames.get(fname) + Fx = self._fontNames.get((fname, subset)) if not first_Fx: first_Fx = Fx if Fx is None: Fx = next(self._internal_font_seq) - self._fontNames[fname] = Fx - _log.debug('Assigning font %s = %r', Fx, fname) + self._fontNames[(fname, subset)] = Fx + _log.debug('Assigning font %s (subset %d) = %r', Fx, subset, fname) if not first_Fx: first_Fx = Fx @@ -950,9 +939,8 @@ def writeFonts(self): for pdfname, dvifont in sorted(self._dviFontInfo.items()): _log.debug('Embedding Type-1 font %s from dvi.', dvifont.texname) fonts[pdfname] = self._embedTeXFont(dvifont) - for filename in sorted(self._fontNames): - Fx = self._fontNames[filename] - _log.debug('Embedding font %s.', filename) + for (filename, subset), Fx in sorted(self._fontNames.items()): + _log.debug('Embedding font %s:%d.', filename, subset) if filename.endswith('.afm'): # from pdf.use14corefonts _log.debug('Writing AFM font.') @@ -960,7 +948,7 @@ def writeFonts(self): else: # a normal TrueType font _log.debug('Writing TrueType font.') - charmap = self._character_tracker.used.get((filename, 0)) + charmap = self._character_tracker.used.get((filename, subset)) if charmap: fonts[Fx] = self.embedTTF(filename, charmap) self.writeObject(self.fontObject, fonts) @@ -1108,13 +1096,6 @@ def createType1Descriptor(self, t1font, fontfile=None): return fontdescObject - def _get_xobject_glyph_name(self, filename, glyph_name): - Fx = self.fontName(filename) - return "-".join([ - Fx.name.decode(), - os.path.splitext(os.path.basename(filename))[0], - glyph_name]) - _identityToUnicodeCMap = b"""/CIDInit /ProcSet findresource begin 12 dict begin begincmap @@ -1159,7 +1140,7 @@ def embedTTFType3(font, charmap, descriptor): fontdictObject = self.reserveObject('font dictionary') charprocsObject = self.reserveObject('character procs') differencesArray = [] - firstchar, lastchar = 0, 255 + firstchar, lastchar = min(charmap), max(charmap) bbox = [cvt(x, nearest=False) for x in font.bbox] fontdict = { @@ -1181,32 +1162,19 @@ def embedTTFType3(font, charmap, descriptor): # Make the "Widths" array def get_char_width(charcode): - width = font.load_char( - charcode, + width = font.load_glyph( + charmap.get(charcode, 0), flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING).horiAdvance return cvt(width) - with warnings.catch_warnings(): - # Ignore 'Required glyph missing from current font' warning - # from ft2font: here we're just building the widths table, but - # the missing glyphs may not even be used in the actual string. - warnings.filterwarnings("ignore") - widths = [get_char_width(charcode) - for charcode in range(firstchar, lastchar+1)] + widths = [get_char_width(charcode) + for charcode in range(firstchar, lastchar+1)] descriptor['MaxWidth'] = max(widths) - # 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. - differences = [] - multi_byte_chars = set() - for ccode, gind in charmap.items(): - glyph_name = font.get_glyph_name(gind) - if ccode is not None and ccode <= 255: - differences.append((ccode, glyph_name)) - else: - multi_byte_chars.add(glyph_name) - differences.sort() - + # Make the "Differences" array with the whole set of character codes that we + # need from this font. + differences = sorted([ + (ccode, font.get_glyph_name(gind)) for ccode, gind in charmap.items() + ]) last_c = -2 for c, name in differences: if c != last_c + 1: @@ -1219,30 +1187,9 @@ def get_char_width(charcode): charprocs = {} for charname in sorted(rawcharprocs): stream = rawcharprocs[charname] - charprocDict = {} - # The 2-byte characters are used as XObjects, so they - # need extra info in their dictionary - if charname in multi_byte_chars: - charprocDict = {'Type': Name('XObject'), - 'Subtype': Name('Form'), - 'BBox': bbox} - # Each glyph includes bounding box information, - # but xpdf and ghostscript can't handle it in a - # Form XObject (they segfault!!!), so we remove it - # from the stream here. It's not needed anyway, - # since the Form XObject includes it in its BBox - # value. - stream = stream[stream.find(b"d1") + 2:] charprocObject = self.reserveObject('charProc') - self.outputStream(charprocObject, stream, extra=charprocDict) - - # Send the glyphs with ccode > 255 to the XObject dictionary, - # and the others to the font itself - if charname in multi_byte_chars: - name = self._get_xobject_glyph_name(filename, charname) - self.multi_byte_charprocs[name] = charprocObject - else: - charprocs[charname] = charprocObject + self.outputStream(charprocObject, stream) + charprocs[charname] = charprocObject # Write everything out self.writeObject(fontdictObject, fontdict) @@ -1271,9 +1218,6 @@ def embedTTFType42(font, charmap, descriptor): os.stat(filename).st_size, fontdata.getbuffer().nbytes ) - # We need this ref for XObjects - full_font = font - # reload the font object from the subset # (all the necessary data could probably be obtained directly # using fontLib.ttLib) @@ -1351,32 +1295,6 @@ def embedTTFType42(font, charmap, descriptor): unicode_cmap = (self._identityToUnicodeCMap % (len(unicode_groups), b"\n".join(unicode_bfrange))) - # Add XObjects for unsupported chars - glyph_indices = [ - glyph_index for ccode, glyph_index in charmap.items() - if not _font_supports_glyph(fonttype, ccode) - ] - - bbox = [cvt(x, nearest=False) for x in full_font.bbox] - rawcharprocs = _get_pdf_charprocs(filename, glyph_indices) - for charname in sorted(rawcharprocs): - stream = rawcharprocs[charname] - charprocDict = {'Type': Name('XObject'), - 'Subtype': Name('Form'), - 'BBox': bbox} - # Each glyph includes bounding box information, - # but xpdf and ghostscript can't handle it in a - # Form XObject (they segfault!!!), so we remove it - # from the stream here. It's not needed anyway, - # since the Form XObject includes it in its BBox - # value. - stream = stream[stream.find(b"d1") + 2:] - charprocObject = self.reserveObject('charProc') - self.outputStream(charprocObject, stream, extra=charprocDict) - - name = self._get_xobject_glyph_name(filename, charname) - self.multi_byte_charprocs[name] = charprocObject - # CIDToGIDMap stream cid_to_gid_map = "".join(cid_to_gid_map).encode("utf-16be") self.outputStream(cidToGidMapObject, cid_to_gid_map) @@ -1396,10 +1314,7 @@ def embedTTFType42(font, charmap, descriptor): # Beginning of main embedTTF function... - ps_name = self._get_subsetted_psname( - font.postscript_name, - font.get_charmap() - ) + ps_name = self._get_subsetted_psname(font.postscript_name, charmap) ps_name = ps_name.encode('ascii', 'replace') ps_name = Name(ps_name) pclt = font.get_sfnt_table('pclt') or {'capHeight': 0, 'xHeight': 0} @@ -2203,30 +2118,22 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): self.check_gc(gc, gc._rgb) prev_font = None, None oldx, oldy = 0, 0 - unsupported_chars = [] self.file.output(Op.begin_text) for font, fontsize, ccode, glyph_index, ox, oy in glyphs: - self.file._character_tracker.track_glyph(font, ccode, glyph_index) + subset_index, subset_charcode = self.file._character_tracker.track_glyph( + font, ccode, glyph_index) fontname = font.fname - 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, glyph_index)) - else: - self._setup_textpos(ox, oy, 0, oldx, oldy) - oldx, oldy = ox, oy - if (fontname, fontsize) != prev_font: - self.file.output(self.file.fontName(fontname), fontsize, - Op.selectfont) - prev_font = fontname, fontsize - self.file.output(self.encode_string(chr(ccode), fonttype), - Op.show) + self._setup_textpos(ox, oy, 0, oldx, oldy) + oldx, oldy = ox, oy + if (fontname, subset_index, fontsize) != prev_font: + self.file.output(self.file.fontName(fontname, subset_index), fontsize, + Op.selectfont) + prev_font = fontname, subset_index, fontsize + self.file.output(self._encode_glyphs([subset_charcode], fonttype), + Op.show) self.file.output(Op.end_text) - 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: self.file.output(Op.gsave, ox, oy, width, height, @@ -2319,6 +2226,11 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): [0, 0]], pathops) self.draw_path(boxgc, path, mytrans, gc._rgb) + def _encode_glyphs(self, subset, fonttype): + if fonttype in (1, 3): + return bytes(subset) + return b''.join(glyph.to_bytes(2, 'big') for glyph in subset) + def encode_string(self, s, fonttype): match fonttype: case 1: @@ -2345,7 +2257,6 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): fonttype = 1 else: font = self._get_font_ttf(prop) - self.file._character_tracker.track(font, s) fonttype = mpl.rcParams['pdf.fonttype'] if gc.get_url() is not None: @@ -2365,23 +2276,23 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # A sequence of characters is broken into multiple chunks. The chunking # serves two purposes: - # - For Type 3 fonts, there is no way to access multibyte characters, - # as they cannot have a CIDMap. Therefore, in this case we break - # the string into chunks, where each chunk contains either a string - # of consecutive 1-byte characters or a single multibyte character. - # - A sequence of 1-byte characters is split into chunks to allow for - # kerning adjustments between consecutive chunks. + # - For Type 3 fonts, there is no way to access multibyte characters, as they + # cannot have a CIDMap. Therefore, in this case we break the string into + # chunks, where each chunk contains a string of consecutive 1-byte + # characters in a 256-character subset of the font. A distinct version of + # the original font is created for each 256-character subset. + # - A sequence of characters is split into chunks to allow for kerning + # adjustments between consecutive chunks. # - # Each chunk is emitted with a separate command: 1-byte characters use - # the regular text show command (TJ) with appropriate kerning between - # chunks, whereas multibyte characters use the XObject command (Do). + # Each chunk is emitted with the regular text show command (TJ) with appropriate + # kerning between chunks. else: def output_singlebyte_chunk(kerns_or_chars): 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) + else self._encode_glyphs(group, fonttype) for tp, group in itertools.groupby(kerns_or_chars, type)], Op.showkern) # Do the rotation and global translation as a single matrix @@ -2393,51 +2304,31 @@ def output_singlebyte_chunk(kerns_or_chars): x, y, Op.concat_matrix) # List of [prev_kern, char, char, ...] w/o zero kerns. singlebyte_chunk = [] - # List of (ft_object, start_x, glyph_index). - multibyte_glyphs = [] prev_font = None prev_start_x = 0 - # Emit all the 1-byte characters in a BT/ET group. + # Emit all the characters in a BT/ET group. self.file.output(Op.begin_text) for item in _text_helpers.layout(s, font, kern_mode=Kerning.UNFITTED, language=language): - if _font_supports_glyph(fonttype, ord(item.char)): - if item.ft_object != prev_font: - if singlebyte_chunk: - output_singlebyte_chunk(singlebyte_chunk) - ft_name = self.file.fontName(item.ft_object.fname) - 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 - prev_start_x = item.x - if item.prev_kern: - singlebyte_chunk.append(item.prev_kern) - singlebyte_chunk.append(item.char) - else: - prev_font = None - multibyte_glyphs.append((item.ft_object, item.x, item.glyph_index)) + subset, charcode = self.file._character_tracker.track_glyph( + item.ft_object, ord(item.char), item.glyph_index) + if (item.ft_object, subset) != prev_font: + if 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.prev_kern: + singlebyte_chunk.append(item.prev_kern) + singlebyte_chunk.append(charcode) if singlebyte_chunk: output_singlebyte_chunk(singlebyte_chunk) self.file.output(Op.end_text) - # Then emit all the multibyte characters, one at a time. - for ft_object, start_x, glyph_index in multibyte_glyphs: - self._draw_xobject_glyph( - ft_object, fontsize, glyph_index, start_x, 0 - ) self.file.output(Op.grestore) - 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_index) - name = self.file._get_xobject_glyph_name(font.fname, glyph_name) - self.file.output( - Op.gsave, - 0.001 * fontsize, 0, 0, 0.001 * fontsize, x, y, Op.concat_matrix, - Name(name), Op.use_xobject, - Op.grestore, - ) - def new_gc(self): # docstring inherited return GraphicsContextPdf(self.file) From 1c4af68657f0017055beada1c7172666cf147001 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 3 Sep 2025 01:17:42 -0400 Subject: [PATCH 2/4] pdf: Correct Unicode mapping for out-of-range font chunks For Type 3 fonts, add a `ToUnicode` mapping (which was added in PDF 1.2), and for Type 42 fonts, correct the Unicode encoding, which should be UTF-16BE, not UCS2. --- lib/matplotlib/backends/_backend_pdf_ps.py | 19 ++++++ lib/matplotlib/backends/backend_pdf.py | 76 +++++++++++++--------- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 1fdcccbab61a..1dde801d8665 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -205,6 +205,25 @@ def track_glyph( self.used.setdefault((font.fname, subset), {})[subset_charcode] = glyph return (subset, subset_charcode) + def subset_to_unicode(self, index: int, + charcode: CharacterCodeType) -> CharacterCodeType: + """ + Map a subset index and character code to a Unicode character code. + + Parameters + ---------- + index : int + The subset index within a font. + charcode : CharacterCodeType + The character code within a subset to map back. + + Returns + ------- + CharacterCodeType + The Unicode character code corresponding to the subsetted one. + """ + return index * self.subset_size + charcode + class RendererPDFPSBase(RendererBase): # The following attributes must be defined by the subclasses: diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 06fa09793553..0f7720b1022f 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -950,7 +950,7 @@ def writeFonts(self): _log.debug('Writing TrueType font.') charmap = self._character_tracker.used.get((filename, subset)) if charmap: - fonts[Fx] = self.embedTTF(filename, charmap) + fonts[Fx] = self.embedTTF(filename, subset, charmap) self.writeObject(self.fontObject, fonts) def _write_afm_font(self, filename): @@ -1117,7 +1117,7 @@ def createType1Descriptor(self, t1font, fontfile=None): end end""" - def embedTTF(self, filename, charmap): + def embedTTF(self, filename, subset_index, charmap): """Embed the TTF font from the named file into the document.""" font = get_font(filename) fonttype = mpl.rcParams['pdf.fonttype'] @@ -1133,12 +1133,40 @@ def cvt(length, upe=font.units_per_EM, nearest=True): else: return math.ceil(value) - def embedTTFType3(font, charmap, descriptor): + def generate_unicode_cmap(subset_index, charmap): + # Make the ToUnicode CMap. + last_ccode = -2 + unicode_groups = [] + for ccode in sorted(charmap.keys()): + if ccode != last_ccode + 1: + unicode_groups.append([ccode, ccode]) + else: + unicode_groups[-1][1] = ccode + last_ccode = ccode + + width = 2 if fonttype == 3 else 4 + unicode_bfrange = [] + for start, end in unicode_groups: + real_start = self._character_tracker.subset_to_unicode(subset_index, + start) + real_end = self._character_tracker.subset_to_unicode(subset_index, end) + real_values = ' '.join('<%s>' % chr(x).encode('utf-16be').hex() + for x in range(real_start, real_end+1)) + unicode_bfrange.append( + f'<{start:0{width}x}> <{end:0{width}x}> [{real_values}]') + unicode_cmap = (self._identityToUnicodeCMap % + (len(unicode_groups), + '\n'.join(unicode_bfrange).encode('ascii'))) + + return unicode_cmap + + def embedTTFType3(font, subset_index, charmap, descriptor): """The Type 3-specific part of embedding a Truetype font""" widthsObject = self.reserveObject('font widths') fontdescObject = self.reserveObject('font descriptor') fontdictObject = self.reserveObject('font dictionary') charprocsObject = self.reserveObject('character procs') + toUnicodeMapObject = self.reserveObject('ToUnicode map') differencesArray = [] firstchar, lastchar = min(charmap), max(charmap) bbox = [cvt(x, nearest=False) for x in font.bbox] @@ -1157,8 +1185,9 @@ def embedTTFType3(font, charmap, descriptor): 'Encoding': { 'Type': Name('Encoding'), 'Differences': differencesArray}, - 'Widths': widthsObject - } + 'Widths': widthsObject, + 'ToUnicode': toUnicodeMapObject, + } # Make the "Widths" array def get_char_width(charcode): @@ -1191,15 +1220,18 @@ def get_char_width(charcode): self.outputStream(charprocObject, stream) charprocs[charname] = charprocObject + unicode_cmap = generate_unicode_cmap(subset_index, charmap) + # Write everything out self.writeObject(fontdictObject, fontdict) self.writeObject(fontdescObject, descriptor) self.writeObject(widthsObject, widths) self.writeObject(charprocsObject, charprocs) + self.outputStream(toUnicodeMapObject, unicode_cmap) return fontdictObject - def embedTTFType42(font, charmap, descriptor): + def embedTTFType42(font, subset_index, charmap, descriptor): """The Type 42-specific part of embedding a Truetype font""" fontdescObject = self.reserveObject('font descriptor') cidFontDictObject = self.reserveObject('CID font dictionary') @@ -1209,12 +1241,12 @@ def embedTTFType42(font, charmap, descriptor): wObject = self.reserveObject('Type 0 widths') toUnicodeMapObject = self.reserveObject('ToUnicode map') - _log.debug("SUBSET %s characters: %s", filename, charmap) + _log.debug("SUBSET %s:%d characters: %s", filename, subset_index, charmap) with _backend_pdf_ps.get_glyphs_subset(filename, charmap.values()) as subset: fontdata = _backend_pdf_ps.font_as_file(subset) _log.debug( - "SUBSET %s %d -> %d", filename, + "SUBSET %s:%d %d -> %d", filename, subset_index, os.stat(filename).st_size, fontdata.getbuffer().nbytes ) @@ -1251,8 +1283,7 @@ def embedTTFType42(font, charmap, descriptor): fontfileObject, fontdata.getvalue(), extra={'Length1': fontdata.getbuffer().nbytes}) - # Make the 'W' (Widths) array, CidToGidMap and ToUnicode CMap - # at the same time + # Make the 'W' (Widths) array and CidToGidMap at the same time. cid_to_gid_map = ['\0'] * 65536 widths = [] max_ccode = 0 @@ -1260,8 +1291,7 @@ def embedTTFType42(font, charmap, descriptor): glyph = font.load_glyph(gind, flags=LoadFlags.NO_SCALE | LoadFlags.NO_HINTING) widths.append((ccode, cvt(glyph.horiAdvance))) - if ccode < 65536: - cid_to_gid_map[ccode] = chr(gind) + cid_to_gid_map[ccode] = chr(gind) max_ccode = max(ccode, max_ccode) widths.sort() cid_to_gid_map = cid_to_gid_map[:max_ccode + 1] @@ -1269,37 +1299,21 @@ def embedTTFType42(font, charmap, descriptor): last_ccode = -2 w = [] max_width = 0 - unicode_groups = [] for ccode, width in widths: if ccode != last_ccode + 1: w.append(ccode) w.append([width]) - unicode_groups.append([ccode, ccode]) else: w[-1].append(width) - unicode_groups[-1][1] = ccode max_width = max(max_width, width) last_ccode = ccode - unicode_bfrange = [] - for start, end in unicode_groups: - # Ensure the CID map contains only chars from BMP - if start > 65535: - continue - end = min(65535, end) - - unicode_bfrange.append( - b"<%04x> <%04x> [%s]" % - (start, end, - b" ".join(b"<%04x>" % x for x in range(start, end+1)))) - unicode_cmap = (self._identityToUnicodeCMap % - (len(unicode_groups), b"\n".join(unicode_bfrange))) - # CIDToGIDMap stream cid_to_gid_map = "".join(cid_to_gid_map).encode("utf-16be") self.outputStream(cidToGidMapObject, cid_to_gid_map) # ToUnicode CMap + unicode_cmap = generate_unicode_cmap(subset_index, charmap) self.outputStream(toUnicodeMapObject, unicode_cmap) descriptor['MaxWidth'] = max_width @@ -1355,9 +1369,9 @@ def embedTTFType42(font, charmap, descriptor): } if fonttype == 3: - return embedTTFType3(font, charmap, descriptor) + return embedTTFType3(font, subset_index, charmap, descriptor) elif fonttype == 42: - return embedTTFType42(font, charmap, descriptor) + return embedTTFType42(font, subset_index, charmap, descriptor) def alphaState(self, alpha): """Return name of an ExtGState that sets alpha to the given value.""" From 6cedcf7094696567e9ee761a43d2935b3c5bb577 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Wed, 3 Sep 2025 01:57:17 -0400 Subject: [PATCH 3/4] TST: Add emoji to multi-font text These characters are outside the BMP and should test subset splitting for type 42 output in PDF. --- lib/matplotlib/testing/__init__.py | 4 +++- lib/matplotlib/tests/test_backend_svg.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index 6a9351ede7f6..d3a6265fab3b 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -276,11 +276,13 @@ def _gen_multi_font_text(): latin1_supplement = [chr(x) for x in range(start, 0xFF+1)] latin_extended_A = [chr(x) for x in range(0x100, 0x17F+1)] latin_extended_B = [chr(x) for x in range(0x180, 0x24F+1)] + non_basic_multilingual_plane = [chr(x) for x in range(0x1F600, 0x1F610)] count = itertools.count(start - 0xA0) non_basic_characters = '\n'.join( ''.join(line) for _, line in itertools.groupby( # Replace with itertools.batched for Py3.12+. - [*latin1_supplement, *latin_extended_A, *latin_extended_B], + [*latin1_supplement, *latin_extended_A, *latin_extended_B, + *non_basic_multilingual_plane], key=lambda x: next(count) // 32) # 32 characters per line. ) test_str = f"""There are basic characters diff --git a/lib/matplotlib/tests/test_backend_svg.py b/lib/matplotlib/tests/test_backend_svg.py index e865dbbe92da..bcac62854580 100644 --- a/lib/matplotlib/tests/test_backend_svg.py +++ b/lib/matplotlib/tests/test_backend_svg.py @@ -526,7 +526,7 @@ def test_svg_metadata(): @image_comparison(["multi_font_aspath.svg"]) -def test_multi_font_type3(): +def test_multi_font_aspath(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('svg', fonttype='path') @@ -537,7 +537,7 @@ def test_multi_font_type3(): @image_comparison(["multi_font_astext.svg"]) -def test_multi_font_type42(): +def test_multi_font_astext(): fonts, test_str = _gen_multi_font_text() plt.rc('font', family=fonts, size=16) plt.rc('svg', fonttype='none') From c908bbfcc05b74766d98848dc617997582ed2d13 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 19 Sep 2025 03:01:02 -0400 Subject: [PATCH 4/4] DOC: Add a release note for PDF font embedding fixes --- doc/release/next_whats_new/pdf_fonts.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 doc/release/next_whats_new/pdf_fonts.rst diff --git a/doc/release/next_whats_new/pdf_fonts.rst b/doc/release/next_whats_new/pdf_fonts.rst new file mode 100644 index 000000000000..4d8665386a72 --- /dev/null +++ b/doc/release/next_whats_new/pdf_fonts.rst @@ -0,0 +1,10 @@ +Improved font embedding in PDF +------------------------------ + +Both Type 3 and Type 42 fonts (see :ref:`fonts` for more details) are now +embedded into PDFs without limitation. Fonts may be split into multiple +embedded subsets in order to satisfy format limits. Additionally, a corrected +Unicode mapping is added for each. + +This means that *all* text should now be selectable and copyable in PDF viewers +that support doing so.