From 4438b257b7cefe0da19a7aceb643087523ea0b67 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Wed, 5 Aug 2020 17:10:01 +0200 Subject: [PATCH] Move PostScript Type3 subsetting to pure python. ... similarly to the change for pdf, but easier because there are no baseline images for which we need to provide bug-level backcompat :-) Drop the FontInfo metadata (which is explicitly optional in the PostScript spec) to avoid having to figure out the correct encoding (which can be quite obscure). Replace the implementation of the `_sc` command from `7 -1 roll{setcachedevice}{pop pop pop pop pop pop}ifelse` to a plain `setcachedevice` (as I cannot see any case where the "other" branch is taken). Drop the splitting of long commands using `exec` (`_e`) -- this is only needed for level-1 postscript, which has a small fixed stack size; we output level-2 postscript (per backend_version) and I guess level-1 is rarely in use nowadays anyways (probably the feature could be added back if there's really demand for it, but let's not get ahead of ourselves). Previously, some composite characters would be output in a "compressed" form (e.g., accented characters would be recorded as "draw the accent, then run the charproc for the unaccented character"). This is lost, but I'd guess outputting .ps.gz is better if compression really matters. --- lib/matplotlib/backends/backend_ps.py | 132 ++++++++++++++---- .../baseline_images/test_backend_ps/type3.eps | 112 +++++++++++++++ lib/matplotlib/tests/test_backend_ps.py | 5 + lib/matplotlib/tests/test_font_manager.py | 7 +- 4 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 lib/matplotlib/tests/baseline_images/test_backend_ps/type3.eps diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 18ed7c239e74..344315d54b88 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -25,8 +25,8 @@ _Backend, _check_savefig_extra_args, FigureCanvasBase, FigureManagerBase, GraphicsContextBase, RendererBase) from matplotlib.cbook import is_writable_file_like, file_requires_unicode -from matplotlib.font_manager import is_opentype_cff_font, get_font -from matplotlib.ft2font import LOAD_NO_HINTING +from matplotlib.font_manager import get_font +from matplotlib.ft2font import LOAD_NO_HINTING, LOAD_NO_SCALE from matplotlib._ttconv import convert_ttf_to_ps from matplotlib.mathtext import MathTextParser from matplotlib._mathtext_data import uni2type1 @@ -134,6 +134,86 @@ def _move_path_to_path_or_stream(src, dst): shutil.move(src, dst, copy_function=shutil.copyfile) +def _font_to_ps_type3(font_path, glyph_ids): + """ + Subset *glyph_ids* from the font at *font_path* into a Type 3 font. + + Parameters + ---------- + font_path : path-like + Path to the font to be subsetted. + glyph_ids : list of int + The glyph indices to include in the subsetted font. + + Returns + ------- + str + The string representation of a Type 3 font, which can be included + verbatim into a PostScript file. + """ + font = get_font(font_path, hinting_factor=1) + + preamble = """\ +%!PS-Adobe-3.0 Resource-Font +%%Creator: Converted from TrueType to Type 3 by Matplotlib. +10 dict begin +/FontName /{font_name} def +/PaintType 0 def +/FontMatrix [{inv_units_per_em} 0 0 {inv_units_per_em} 0 0] def +/FontBBox [{bbox}] def +/FontType 3 def +/Encoding [{encoding}] def +/CharStrings {num_glyphs} dict dup begin +/.notdef 0 def +""".format(font_name=font.postscript_name, + inv_units_per_em=1 / font.units_per_EM, + bbox=" ".join(map(str, font.bbox)), + encoding=" ".join("/{}".format(font.get_glyph_name(glyph_id)) + for glyph_id in glyph_ids), + num_glyphs=len(glyph_ids) + 1) + postamble = """ +end readonly def + +/BuildGlyph { + exch begin + CharStrings exch + 2 copy known not {pop /.notdef} if + true 3 1 roll get exec + end +} d + +/BuildChar { + 1 index /Encoding get exch get + 1 index /BuildGlyph get exec +} d + +FontName currentdict end definefont pop +""" + + entries = [] + for glyph_id in glyph_ids: + g = font.load_glyph(glyph_id, LOAD_NO_SCALE) + v, c = font.get_path() + entries.append( + "/%(name)s{%(bbox)s sc\n" % { + "name": font.get_glyph_name(glyph_id), + "bbox": " ".join(map(str, [g.horiAdvance, 0, *g.bbox])), + } + + _path.convert_to_string( + # Convert back to TrueType's internal units (1/64's). + # (Other dimensions are already in these units.) + Path(v * 64, c), None, None, False, None, 0, + # No code for quad Beziers triggers auto-conversion to cubics. + # Drop intermediate closepolys (relying on the outline + # decomposer always explicitly moving to the closing point + # first). + [b"m", b"l", b"", b"c", b""], True).decode("ascii") + + "ce} d" + ) + + return preamble + "\n".join(entries) + postamble + + class RendererPS(_backend_pdf_ps.RendererPDFPSBase): """ The renderer handles all the drawing primitives using a graphics @@ -922,22 +1002,18 @@ def print_figure_impl(fh): # Can't use more than 255 chars from a single Type 3 font. if len(glyph_ids) > 255: fonttype = 42 - # The ttf to ps (subsetting) support doesn't work for - # OpenType fonts that are Postscript inside (like the STIX - # fonts). This will simply turn that off to avoid errors. - if is_opentype_cff_font(font_path): - raise RuntimeError( - "OpenType CFF fonts can not be saved using " - "the internal Postscript backend at this " - "time; consider using the Cairo backend") fh.flush() - try: - convert_ttf_to_ps(os.fsencode(font_path), - fh, fonttype, glyph_ids) - except RuntimeError: - _log.warning("The PostScript backend does not " - "currently support the selected font.") - raise + if fonttype == 3: + fh.write(_font_to_ps_type3(font_path, glyph_ids)) + else: + try: + convert_ttf_to_ps(os.fsencode(font_path), + fh, fonttype, glyph_ids) + except RuntimeError: + _log.warning( + "The PostScript backend does not currently " + "support the selected font.") + raise print("end", file=fh) print("%%EndProlog", file=fh) @@ -1312,16 +1388,20 @@ def pstoeps(tmpfile, bbox=None, rotated=False): # The usage comments use the notation of the operator summary # in the PostScript Language reference manual. psDefs = [ + # name proc *d* - + "/d { bind def } bind def", # x y *m* - - "/m { moveto } bind def", + "/m { moveto } d", # x y *l* - - "/l { lineto } bind def", + "/l { lineto } d", # x y *r* - - "/r { rlineto } bind def", + "/r { rlineto } d", # x1 y1 x2 y2 x y *c* - - "/c { curveto } bind def", - # *closepath* - - "/cl { closepath } bind def", + "/c { curveto } d", + # *cl* - + "/cl { closepath } d", + # *ce* - + "/ce { closepath eofill } d", # w h x y *box* - """/box { m @@ -1329,13 +1409,15 @@ def pstoeps(tmpfile, bbox=None, rotated=False): 0 exch r neg 0 r cl - } bind def""", + } d""", # w h x y *clipbox* - """/clipbox { box clip newpath - } bind def""", + } d""", + # wx wy llx lly urx ury *setcachedevice* - + "/sc { setcachedevice } d", ] diff --git a/lib/matplotlib/tests/baseline_images/test_backend_ps/type3.eps b/lib/matplotlib/tests/baseline_images/test_backend_ps/type3.eps new file mode 100644 index 000000000000..9c9645b47cf0 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/test_backend_ps/type3.eps @@ -0,0 +1,112 @@ +%!PS-Adobe-3.0 EPSF-3.0 +%%Orientation: portrait +%%BoundingBox: 18.0 180.0 594.0 612.0 +%%EndComments +%%BeginProlog +/mpldict 11 dict def +mpldict begin +/d { bind def } bind def +/m { moveto } d +/l { lineto } d +/r { rlineto } d +/c { curveto } d +/cl { closepath } d +/ce { closepath eofill } d +/box { + m + 1 index 0 r + 0 exch r + neg 0 r + cl + } d +/clipbox { + box + clip + newpath + } d +/sc { setcachedevice } d +%!PS-Adobe-3.0 Resource-Font +%%Creator: Converted from TrueType to Type 3 by Matplotlib. +10 dict begin +/FontName /DejaVuSans def +/PaintType 0 def +/FontMatrix [0.00048828125 0 0 0.00048828125 0 0] def +/FontBBox [-2090 -948 3673 2524] def +/FontType 3 def +/Encoding [/I /J /slash] def +/CharStrings 4 dict dup begin +/.notdef 0 def +/I{604 0 201 0 403 1493 sc +201 1493 m +403 1493 l +403 0 l +201 0 l +201 1493 l + +ce} d +/J{604 0 -106 -410 403 1493 sc +201 1493 m +403 1493 l +403 104 l +403 -76 369 -207 300 -288 c +232 -369 122 -410 -29 -410 c +-106 -410 l +-106 -240 l +-43 -240 l +46 -240 109 -215 146 -165 c +183 -115 201 -25 201 104 c +201 1493 l + +ce} d +/slash{690 0 0 -190 690 1493 sc +520 1493 m +690 1493 l +170 -190 l +0 -190 l +520 1493 l + +ce} d +end readonly def + +/BuildGlyph { + exch begin + CharStrings exch + 2 copy known not {pop /.notdef} if + true 3 1 roll get exec + end +} d + +/BuildChar { + 1 index /Encoding get exch get + 1 index /BuildGlyph get exec +} d + +FontName currentdict end definefont pop +end +%%EndProlog +mpldict begin +18 180 translate +576 432 0 0 clipbox +gsave +0 0 m +576 0 l +576 432 l +0 432 l +cl +1.000 setgray +fill +grestore +0.000 setgray +/DejaVuSans findfont +12.000 scalefont +setfont +gsave +288.000000 216.000000 translate +0.000000 rotate +0.000000 0 m /I glyphshow +3.539062 0 m /slash glyphshow +7.582031 0 m /J glyphshow +grestore + +end +showpage diff --git a/lib/matplotlib/tests/test_backend_ps.py b/lib/matplotlib/tests/test_backend_ps.py index e303e51c84a4..09c953337d45 100644 --- a/lib/matplotlib/tests/test_backend_ps.py +++ b/lib/matplotlib/tests/test_backend_ps.py @@ -157,3 +157,8 @@ def test_useafm(): ax.set_axis_off() ax.axhline(.5) ax.text(.5, .5, "qk") + + +@image_comparison(["type3.eps"]) +def test_type3_font(): + plt.figtext(.5, .5, "I/J") diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index 74af2a7fd570..a471c866a555 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -120,11 +120,8 @@ def test_find_ttc(): fig, ax = plt.subplots() ax.text(.5, .5, "\N{KANGXI RADICAL DRAGON}", fontproperties=fp) - fig.savefig(BytesIO(), format="raw") - fig.savefig(BytesIO(), format="svg") - fig.savefig(BytesIO(), format="pdf") - with pytest.raises(RuntimeError): - fig.savefig(BytesIO(), format="ps") + for fmt in ["raw", "svg", "pdf", "ps"]: + fig.savefig(BytesIO(), format=fmt) def test_find_invalid(tmpdir):