Repository: fonttools/fonttools

Issue:
Cross-platform Rasterisation Pen?
Is there a handy way to rasterise the paths obtained via pen protocol in a cross-platform manner?

- `CocoaPen`/`QuartzPen` is pretty handy unless you are on a non-macOS environment (e.g. Google Colab)
- `QtPen`/`WxPen` is useful only if it's already used within your codebase as a widget toolkit
- `skia-pathops` is a library dedicated to boolean operations but not for drawing

I might be missing something, but I couldn't find a solution that fully suits my needs. After all, I wrote a pen that finds the existing FreeType installation on the system, constructs `FT_Outline` and let FreeType rasterise the paths. As mentioned in #1769, I agree this is out of scope, but on the other hand I think it makes more sense to wrap it as a pen. If you are interested, I'll share and compile a PR.


Discussion:
I think it would be nice to have a pen that uses freetype-py to rasterize a glyph, yes please send a PR thanks!

Resolving patch:
diff --git a/Lib/fontTools/pens/freetypePen.py b/Lib/fontTools/pens/freetypePen.py
new file mode 100644
--- /dev/null
+++ b/Lib/fontTools/pens/freetypePen.py
@@ -0,0 +1,354 @@
+# -*- coding: utf-8 -*-
+
+"""Pen to rasterize paths with FreeType."""
+
+__all__ = ['FreeTypePen']
+
+import os
+import ctypes
+import platform
+import subprocess
+import collections
+import math
+
+import freetype
+from freetype.raw import FT_Outline_Get_Bitmap, FT_Outline_Get_BBox, FT_Outline_Get_CBox
+from freetype.ft_types import FT_Pos
+from freetype.ft_structs import FT_Vector, FT_BBox, FT_Bitmap, FT_Outline
+from freetype.ft_enums import FT_OUTLINE_NONE, FT_OUTLINE_EVEN_ODD_FILL, FT_PIXEL_MODE_GRAY
+from freetype.ft_errors import FT_Exception
+
+from fontTools.pens.basePen import BasePen
+from fontTools.misc.roundTools import otRound
+from fontTools.misc.transform import Transform
+
+Contour   = collections.namedtuple('Contour', ('points', 'tags'))
+LINE      = 0b00000001
+CURVE     = 0b00000011
+OFFCURVE  = 0b00000010
+QCURVE    = 0b00000001
+QOFFCURVE = 0b00000000
+
+class FreeTypePen(BasePen):
+    """Pen to rasterize paths with FreeType. Requires `freetype-py` module.
+
+    Constructs ``FT_Outline`` from the paths, and renders it within a bitmap
+    buffer.
+
+    For ``array()`` and ``show()``, `numpy` and `matplotlib` must be installed.
+    For ``image()``, `Pillow` is required. Each module is lazily loaded when the
+    corresponding method is called.
+
+    Args:
+        glyphSet: a dictionary of drawable glyph objects keyed by name
+            used to resolve component references in composite glyphs.
+
+    :Examples:
+        If `numpy` and `matplotlib` is available, the following code will
+        show the glyph image of `fi` in a new window::
+
+            from fontTools.ttLib import TTFont
+            from fontTools.pens.freetypePen import FreeTypePen
+            from fontTools.misc.transform import Offset
+            pen = FreeTypePen(None)
+            font = TTFont('SourceSansPro-Regular.otf')
+            glyph = font.getGlyphSet()['fi']
+            glyph.draw(pen)
+            width, ascender, descender = glyph.width, font['OS/2'].usWinAscent, -font['OS/2'].usWinDescent
+            height = ascender - descender
+            pen.show(width=width, height=height, transform=Offset(0, -descender))
+
+        Combining with `uharfbuzz`, you can typeset a chunk of glyphs in a pen::
+
+            import uharfbuzz as hb
+            from fontTools.pens.freetypePen import FreeTypePen
+            from fontTools.pens.transformPen import TransformPen
+            from fontTools.misc.transform import Offset
+
+            en1, en2, ar, ja = 'Typesetting', 'Jeff', 'صف الحروف', 'たいぷせっと'
+            for text, font_path, direction, typo_ascender, typo_descender, vhea_ascender, vhea_descender, contain, features in (
+                (en1, 'NotoSans-Regular.ttf',       'ltr', 2189, -600, None, None, False, {"kern": True, "liga": True}),
+                (en2, 'NotoSans-Regular.ttf',       'ltr', 2189, -600, None, None, True,  {"kern": True, "liga": True}),
+                (ar,  'NotoSansArabic-Regular.ttf', 'rtl', 1374, -738, None, None, False, {"kern": True, "liga": True}),
+                (ja,  'NotoSansJP-Regular.otf',     'ltr', 880,  -120, 500,  -500, False, {"palt": True, "kern": True}),
+                (ja,  'NotoSansJP-Regular.otf',     'ttb', 880,  -120, 500,  -500, False, {"vert": True, "vpal": True, "vkrn": True})
+            ):
+                blob = hb.Blob.from_file_path(font_path)
+                face = hb.Face(blob)
+                font = hb.Font(face)
+                buf = hb.Buffer()
+                buf.direction = direction
+                buf.add_str(text)
+                buf.guess_segment_properties()
+                hb.shape(font, buf, features)
+
+                x, y = 0, 0
+                pen = FreeTypePen(None)
+                for info, pos in zip(buf.glyph_infos, buf.glyph_positions):
+                    gid = info.codepoint
+                    transformed = TransformPen(pen, Offset(x + pos.x_offset, y + pos.y_offset))
+                    font.draw_glyph_with_pen(gid, transformed)
+                    x += pos.x_advance
+                    y += pos.y_advance
+
+                offset, width, height = None, None, None
+                if direction in ('ltr', 'rtl'):
+                    offset = (0, -typo_descender)
+                    width  = x
+                    height = typo_ascender - typo_descender
+                else:
+                    offset = (-vhea_descender, -y)
+                    width  = vhea_ascender - vhea_descender
+                    height = -y
+                pen.show(width=width, height=height, transform=Offset(*offset), contain=contain)
+
+        For Jupyter Notebook, the rendered image will be displayed in a cell if
+        you replace ``show()`` with ``image()`` in the examples.
+    """
+
+    def __init__(self, glyphSet):
+        BasePen.__init__(self, glyphSet)
+        self.contours = []
+
+    def outline(self, transform=None, evenOdd=False):
+        """Converts the current contours to ``FT_Outline``.
+
+        Args:
+            transform: A optional 6-tuple containing an affine transformation,
+                or a ``Transform`` object from the ``fontTools.misc.transform``
+                module.
+            evenOdd: Pass ``True`` for even-odd fill instead of non-zero.
+        """
+        transform = transform or Transform()
+        if not hasattr(transform, 'transformPoint'):
+            transform = Transform(*transform)
+        nContours = len(self.contours)
+        n_points  = sum((len(contour.points) for contour in self.contours))
+        points = []
+        for contour in self.contours:
+            for point in contour.points:
+                point = transform.transformPoint(point)
+                points.append(FT_Vector(FT_Pos(otRound(point[0] * 64)), FT_Pos(otRound(point[1] * 64))))
+        tags = []
+        for contour in self.contours:
+            for tag in contour.tags:
+                tags.append(tag)
+        contours = []
+        contours_sum = 0
+        for contour in self.contours:
+            contours_sum += len(contour.points)
+            contours.append(contours_sum - 1)
+        flags = FT_OUTLINE_EVEN_ODD_FILL if evenOdd else FT_OUTLINE_NONE
+        return FT_Outline(
+            (ctypes.c_short)(nContours),
+            (ctypes.c_short)(n_points),
+            (FT_Vector      * n_points)(*points),
+            (ctypes.c_ubyte * n_points)(*tags),
+            (ctypes.c_short * nContours)(*contours),
+            (ctypes.c_int)(flags)
+        )
+
+    def buffer(self, width=None, height=None, transform=None, contain=False, evenOdd=False):
+        """Renders the current contours within a bitmap buffer.
+
+        Args:
+            width: Image width of the bitmap in pixels. If omitted, it
+                automatically fits to the bounding box of the contours.
+            height: Image height of the bitmap in pixels. If omitted, it
+                automatically fits to the bounding box of the contours.
+            transform: A optional 6-tuple containing an affine transformation,
+                or a ``Transform`` object from the ``fontTools.misc.transform``
+                module. The bitmap size is not affected by this matrix.
+            contain: If ``True``, the image size will be automatically expanded
+                so that it fits to the bounding box of the paths. Useful for
+                rendering glyphs with negative sidebearings without clipping.
+            evenOdd: Pass ``True`` for even-odd fill instead of non-zero.
+
+        Returns:
+            A tuple of ``(buffer, size)``, where ``buffer`` is a ``bytes``
+            object of the resulted bitmap and ``size`` is a 2-tuple of its
+            dimension.
+
+        :Example:
+            >> pen = FreeTypePen(None)
+            >> glyph.draw(pen)
+            >> buf, size = pen.buffer(width=500, height=1000)
+            >> type(buf), len(buf), size
+            (<class 'bytes'>, 500000, (500, 1000))
+        """
+        transform = transform or Transform()
+        if not hasattr(transform, 'transformPoint'):
+            transform = Transform(*transform)
+        contain_x, contain_y = contain or width is None, contain or height is None
+        width, height = width or 0, height or 0
+        if contain_x or contain_y:
+            bbox = self.bbox
+            bbox = transform.transformPoints((bbox[0:2], bbox[2:4]))
+            bbox = (*bbox[0], *bbox[1])
+            bbox_size = bbox[2] - bbox[0], bbox[3] - bbox[1]
+            dx, dy = transform.dx, transform.dy
+            if contain_x:
+                dx = min(-dx, bbox[0]) * -1.0
+                width  = max(width,  bbox_size[0])
+            if contain_y:
+                dy = min(-dy, bbox[1]) * -1.0
+                height = max(height, bbox_size[1])
+            transform = Transform(*transform[:4], dx, dy)
+        width, height = math.ceil(width), math.ceil(height)
+        buf = ctypes.create_string_buffer(width * height)
+        bitmap = FT_Bitmap(
+            (ctypes.c_int)(height),
+            (ctypes.c_int)(width),
+            (ctypes.c_int)(width),
+            (ctypes.POINTER(ctypes.c_ubyte))(buf),
+            (ctypes.c_short)(256),
+            (ctypes.c_ubyte)(FT_PIXEL_MODE_GRAY),
+            (ctypes.c_char)(0),
+            (ctypes.c_void_p)(None)
+        )
+        outline = self.outline(transform=transform, evenOdd=evenOdd)
+        err = FT_Outline_Get_Bitmap(freetype.get_handle(), ctypes.byref(outline), ctypes.byref(bitmap))
+        if err != 0:
+            raise FT_Exception(err)
+        return buf.raw, (width, height)
+
+    def array(self, width=None, height=None, transform=None, contain=False, evenOdd=False):
+        """Returns the rendered contours as a numpy array. Requires `numpy`.
+
+        Args:
+            width: Image width of the bitmap in pixels. If omitted, it
+                automatically fits to the bounding box of the contours.
+            height: Image height of the bitmap in pixels. If omitted, it
+                automatically fits to the bounding box of the contours.
+            transform: A optional 6-tuple containing an affine transformation,
+                or a ``Transform`` object from the ``fontTools.misc.transform``
+                module. The bitmap size is not affected by this matrix.
+            contain: If ``True``, the image size will be automatically expanded
+                so that it fits to the bounding box of the paths. Useful for
+                rendering glyphs with negative sidebearings without clipping.
+            evenOdd: Pass ``True`` for even-odd fill instead of non-zero.
+
+        Returns:
+            A ``numpy.ndarray`` object with a shape of ``(height, width)``.
+            Each element takes a value in the range of ``[0.0, 1.0]``.
+
+        :Example:
+            >> pen = FreeTypePen(None)
+            >> glyph.draw(pen)
+            >> arr = pen.array(width=500, height=1000)
+            >> type(a), a.shape
+            (<class 'numpy.ndarray'>, (1000, 500))
+        """
+        import numpy as np
+        buf, size = self.buffer(width=width, height=height, transform=transform, contain=contain, evenOdd=evenOdd)
+        return np.frombuffer(buf, 'B').reshape((size[1], size[0])) / 255.0
+
+    def show(self, width=None, height=None, transform=None, contain=False, evenOdd=False):
+        """Plots the rendered contours with `pyplot`. Requires `numpy` and
+        `matplotlib`.
+
+        Args:
+            width: Image width of the bitmap in pixels. If omitted, it
+                automatically fits to the bounding box of the contours.
+            height: Image height of the bitmap in pixels. If omitted, it
+                automatically fits to the bounding box of the contours.
+            transform: A optional 6-tuple containing an affine transformation,
+                or a ``Transform`` object from the ``fontTools.misc.transform``
+                module. The bitmap size is not affected by this matrix.
+            contain: If ``True``, the image size will be automatically expanded
+                so that it fits to the bounding box of the paths. Useful for
+                rendering glyphs with negative sidebearings without clipping.
+            evenOdd: Pass ``True`` for even-odd fill instead of non-zero.
+
+        :Example:
+            >> pen = FreeTypePen(None)
+            >> glyph.draw(pen)
+            >> pen.show(width=500, height=1000)
+        """
+        from matplotlib import pyplot as plt
+        a = self.array(width=width, height=height, transform=transform, contain=contain, evenOdd=evenOdd)
+        plt.imshow(a, cmap='gray_r', vmin=0, vmax=1)
+        plt.show()
+
+    def image(self, width=None, height=None, transform=None, contain=False, evenOdd=False):
+        """Returns the rendered contours as a PIL image. Requires `Pillow`.
+        Can be used to display a glyph image in Jupyter Notebook.
+
+        Args:
+            width: Image width of the bitmap in pixels. If omitted, it
+                automatically fits to the bounding box of the contours.
+            height: Image height of the bitmap in pixels. If omitted, it
+                automatically fits to the bounding box of the contours.
+            transform: A optional 6-tuple containing an affine transformation,
+                or a ``Transform`` object from the ``fontTools.misc.transform``
+                module. The bitmap size is not affected by this matrix.
+            contain: If ``True``, the image size will be automatically expanded
+                so that it fits to the bounding box of the paths. Useful for
+                rendering glyphs with negative sidebearings without clipping.
+            evenOdd: Pass ``True`` for even-odd fill instead of non-zero.
+
+        Returns:
+            A ``PIL.image`` object. The image is filled in black with alpha
+            channel obtained from the rendered bitmap.
+
+        :Example:
+            >> pen = FreeTypePen(None)
+            >> glyph.draw(pen)
+            >> img = pen.image(width=500, height=1000)
+            >> type(img), img.size
+            (<class 'PIL.Image.Image'>, (500, 1000))
+        """
+        from PIL import Image
+        buf, size = self.buffer(width=width, height=height, transform=transform, contain=contain, evenOdd=evenOdd)
+        img = Image.new('L', size, 0)
+        img.putalpha(Image.frombuffer('L', size, buf))
+        return img
+
+    @property
+    def bbox(self):
+        """Computes the exact bounding box of an outline.
+
+        Returns:
+            A tuple of ``(xMin, yMin, xMax, yMax)``.
+        """
+        bbox = FT_BBox()
+        outline = self.outline()
+        FT_Outline_Get_BBox(ctypes.byref(outline), ctypes.byref(bbox))
+        return (bbox.xMin / 64.0, bbox.yMin / 64.0, bbox.xMax / 64.0, bbox.yMax / 64.0)
+
+    @property
+    def cbox(self):
+        """Returns an outline's ‘control box’.
+
+        Returns:
+            A tuple of ``(xMin, yMin, xMax, yMax)``.
+        """
+        cbox = FT_BBox()
+        outline = self.outline()
+        FT_Outline_Get_CBox(ctypes.byref(outline), ctypes.byref(cbox))
+        return (cbox.xMin / 64.0, cbox.yMin / 64.0, cbox.xMax / 64.0, cbox.yMax / 64.0)
+
+    def _moveTo(self, pt):
+        contour = Contour([], [])
+        self.contours.append(contour)
+        contour.points.append(pt)
+        contour.tags.append(LINE)
+
+    def _lineTo(self, pt):
+        contour = self.contours[-1]
+        contour.points.append(pt)
+        contour.tags.append(LINE)
+
+    def _curveToOne(self, p1, p2, p3):
+        t1, t2, t3 = OFFCURVE, OFFCURVE, CURVE
+        contour = self.contours[-1]
+        for p, t in ((p1, t1), (p2, t2), (p3, t3)):
+            contour.points.append(p)
+            contour.tags.append(t)
+
+    def _qCurveToOne(self, p1, p2):
+        t1, t2 = QOFFCURVE, QCURVE
+        contour = self.contours[-1]
+        for p, t in ((p1, t1), (p2, t2)):
+            contour.points.append(p)
+            contour.tags.append(t)


Test patch:
diff --git a/Tests/pens/data/test_even_odd_fill.pgm b/Tests/pens/data/test_even_odd_fill.pgm
new file mode 100644
Binary files /dev/null and b/Tests/pens/data/test_even_odd_fill.pgm differ
diff --git a/Tests/pens/data/test_non_zero_fill.pgm b/Tests/pens/data/test_non_zero_fill.pgm
new file mode 100644
Binary files /dev/null and b/Tests/pens/data/test_non_zero_fill.pgm differ
diff --git a/Tests/pens/freetypePen_test.py b/Tests/pens/freetypePen_test.py
new file mode 100644
--- /dev/null
+++ b/Tests/pens/freetypePen_test.py
@@ -0,0 +1,159 @@
+import unittest
+import os
+
+try:
+    from fontTools.pens.freetypePen import FreeTypePen
+    FREETYPE_PY_AVAILABLE = True
+except ImportError:
+    FREETYPE_PY_AVAILABLE = False
+
+from fontTools.misc.transform import Scale, Offset
+
+DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')
+
+def box(pen):
+    pen.moveTo((0, 0))
+    pen.lineTo((0, 500))
+    pen.lineTo((500, 500))
+    pen.lineTo((500, 0))
+    pen.closePath()
+
+def draw_cubic(pen):
+    pen.moveTo((50, 0))
+    pen.lineTo((50, 500))
+    pen.lineTo((200, 500))
+    pen.curveTo((350, 500), (450, 400), (450, 250))
+    pen.curveTo((450, 100), (350, 0), (200, 0))
+    pen.closePath()
+
+def draw_quadratic(pen):
+    pen.moveTo((50, 0))
+    pen.lineTo((50, 500))
+    pen.lineTo((200, 500))
+    pen.qCurveTo((274, 500), (388, 438), (450, 324), (450, 250))
+    pen.qCurveTo((450, 176), (388, 62), (274, 0), (200, 0))
+    pen.closePath()
+
+def star(pen):
+    pen.moveTo((0, 420))
+    pen.lineTo((1000, 420))
+    pen.lineTo((200, -200))
+    pen.lineTo((500, 800))
+    pen.lineTo((800, -200))
+    pen.closePath()
+
+# For the PGM format, see the following resources:
+#   https://en.wikipedia.org/wiki/Netpbm
+#   http://netpbm.sourceforge.net/doc/pgm.html
+def load_pgm(filename):
+    with open(filename, 'rb') as fp:
+        assert fp.readline() == 'P5\n'.encode()
+        w, h = (int(c) for c in fp.readline().decode().rstrip().split(' '))
+        assert fp.readline() == '255\n'.encode()
+        return fp.read(), (w, h)
+
+def save_pgm(filename, buf, size):
+    with open(filename, 'wb') as fp:
+        fp.write('P5\n'.encode())
+        fp.write('{0:d} {1:d}\n'.format(*size).encode())
+        fp.write('255\n'.encode())
+        fp.write(buf)
+
+# Assume the buffers are equal when PSNR > 38dB. See also:
+#   Peak signal-to-noise ratio
+#   https://en.wikipedia.org/wiki/Peak_signal-to-noise_ratio
+PSNR_THRESHOLD = 38.0
+
+def psnr(b1, b2):
+    import math
+    mse = sum((c1-c2) * (c1-c2) for c1, c2 in zip(b1, b2)) / float(len(b1))
+    return 10.0 * math.log10((255.0 ** 2) / float(mse)) if mse > 0 else math.inf
+
+@unittest.skipUnless(FREETYPE_PY_AVAILABLE, "freetype-py not installed")
+class FreeTypePenTest(unittest.TestCase):
+    def test_draw(self):
+        pen = FreeTypePen(None)
+        box(pen)
+        width, height = 500, 500
+        buf1, _ = pen.buffer(width=width, height=height)
+        buf2 = b'\xff' * width * height
+        self.assertEqual(buf1, buf2)
+
+    def test_empty(self):
+        pen = FreeTypePen(None)
+        width, height = 500, 500
+        buf, size = pen.buffer(width=width, height=height)
+        self.assertEqual(b'\0' * size[0] * size[1], buf)
+
+    def test_bbox_and_cbox(self):
+        pen = FreeTypePen(None)
+        draw_cubic(pen)
+        self.assertEqual(pen.bbox, (50.0, 0.0, 450.0, 500.0))
+        self.assertEqual(pen.cbox, (50.0, 0.0, 450.0, 500.0))
+
+    def test_non_zero_fill(self):
+        pen = FreeTypePen(None)
+        star(pen)
+        t = Scale(0.05, 0.05)
+        width, height = t.transformPoint((1000, 1000))
+        t = t.translate(0, 200)
+        buf1, size1 = pen.buffer(width=width, height=height, transform=t, evenOdd=False)
+        buf2, size2 = load_pgm(os.path.join(DATA_DIR, 'test_non_zero_fill.pgm'))
+        self.assertEqual(len(buf1), len(buf2))
+        self.assertEqual(size1, size2)
+        self.assertGreater(psnr(buf1, buf2), PSNR_THRESHOLD)
+
+    def test_even_odd_fill(self):
+        pen = FreeTypePen(None)
+        star(pen)
+        t = Scale(0.05, 0.05)
+        width, height = t.transformPoint((1000, 1000))
+        t = t.translate(0, 200)
+        buf1, size1 = pen.buffer(width=width, height=height, transform=t, evenOdd=True)
+        buf2, size2 = load_pgm(os.path.join(DATA_DIR, 'test_even_odd_fill.pgm'))
+        self.assertEqual(len(buf1), len(buf2))
+        self.assertEqual(size1, size2)
+        self.assertGreater(psnr(buf1, buf2), PSNR_THRESHOLD)
+
+    def test_cubic_vs_quadratic(self):
+        pen1, pen2 = FreeTypePen(None), FreeTypePen(None)
+        draw_cubic(pen1)
+        draw_quadratic(pen2)
+        width, height = 500, 500
+        buf1, _ = pen1.buffer(width=width, height=height)
+        buf2, _ = pen2.buffer(width=width, height=height)
+        self.assertEqual(len(buf1), len(buf2))
+        self.assertGreater(psnr(buf1, buf2), PSNR_THRESHOLD)
+
+    def test_contain(self):
+        pen = FreeTypePen(None)
+        star(pen)
+        t = Scale(0.05, 0.05)
+        width, height = 0, 0
+        buf1, size1 = pen.buffer(width=width, height=height, transform=t, contain=True)
+        buf2, size2 = load_pgm(os.path.join(DATA_DIR, 'test_non_zero_fill.pgm'))
+        self.assertEqual(len(buf1), len(buf2))
+        self.assertEqual(size1, size2)
+        self.assertGreater(psnr(buf1, buf2), PSNR_THRESHOLD)
+
+    def test_none_width(self):
+        pen = FreeTypePen(None)
+        star(pen)
+        width, height = None, 1000
+        buf1, size = pen.buffer(width=width, height=height, transform=Offset(0, 200))
+        buf2, _    = pen.buffer(width=1000,  height=height, transform=Offset(0, 200))
+        self.assertEqual(size, (1000, 1000))
+        self.assertEqual(buf1, buf2)
+
+    def test_none_height(self):
+        pen = FreeTypePen(None)
+        star(pen)
+        width, height = 1000, None
+        buf1, size = pen.buffer(width=width, height=height)
+        buf2, _    = pen.buffer(width=width, height=1000, transform=Offset(0, 200))
+        self.assertEqual(size, (1000, 1000))
+        self.assertEqual(buf1, buf2)
+
+if __name__ == '__main__':
+    import sys
+    sys.exit(unittest.main())