8 Outlines
dxtr edited this page 2026-10-18 16:27:11 +02:00

Outlines

Glyphs from scalable fonts are vector outlines until they are rendered. cl-ft2 returns them as plain Lisp lists, ready for your own rasteriser, an SDF/MSDF generator, a GPU curve renderer, triangulation, or export to SVG. Outlines of your own, made from arrays, FreeType strokes and rasterizes for you (see below).

glyph-outline source → list of contours

Returns the outline of the glyph in a face's slot, or of a glyph object. The result is a list of contours. Each contour is a list of segments that starts with :move-to:

Segment Meaning
(:move-to x y) start a contour at (x, y)
(:line-to x y) straight line to (x, y)
(:conic-to cx cy x y) quadratic Bézier with control point (cx, cy) to (x, y)
(:cubic-to c1x c1y c2x c2y x y) cubic Bézier with control points (c1x, c1y), (c2x, c2y) to (x, y)

Each segment starts where the previous one ended. Contours are closed: when a font's contour does not end at its start, FreeType adds a final segment back to the :move-to point.

(setf (ft2:face-size face) 32)
(ft2:load-char face #\O)
(ft2:glyph-outline face)
;; => (((:MOVE-TO 807/64 20)
;;      (:CONIC-TO 587/64 20 457/64 567/32)
;;      (:CONIC-TO 41/8 247/16 41/8 23/2)
;;      …
;;      (:CONIC-TO 1027/64 20 807/64 20))   ; the counter (inner contour)
;;     ((:MOVE-TO 807/64 23) …))            ; the outer contour

Coordinates are in pixels, as exact rationals with y growing up from the baseline, or in font units if the glyph was loaded with :no-scale. Hinting applies: for a raw, size-independent outline, load with :no-scale (font units) or :no-hinting (scaled but not grid-fitted).

Curve types: TrueType (glyf) fonts produce :conic-to segments. CFF/CFF2 (.otf) and Type 1 fonts produce :cubic-to. Implied on-curve points between consecutive conic control points are made explicit, so every segment is a complete Bézier.

Contour order is the font's order: here the counter comes before the outer contour. Do not rely on any order.

Winding: TrueType outlines wind outer contours clockwise, PostScript ones counter-clockwise. Fill with the non-zero rule, which is correct for both.

The glyph must be in :outline format. A rendered glyph, a glyph loaded with :render, or one from a bitmap strike signals :invalid-glyph-format:

Glyph 36 is in bitmap format, not an outline.
Only outline glyphs have outlines: load the glyph without the :RENDER
flag, from a scalable face.  A rendered glyph is read with GLYPH-BITMAP.

The outline reflects the face's transform (face-transform-matrix, face-transform-delta) and variation coordinates at load time. For a glyph object, it also reflects any transform-glyph or stroke-glyph applied since.

glyph-control-box source → xmin, ymin, xmax, ymax

The control box, as four values in the same units as glyph-outline. It encloses every outline point, control points included, so it can be slightly larger than the tight bounding box. It is cheap and exact enough for layout and atlas packing.

(ft2:load-char face #\O)
(ft2:glyph-control-box face)   ; => 115/64, 0, 1497/64, 23

For the glyph slot, the glyph must be an outline. A glyph object may also be a bitmap, whose box is its pixel extent.

Outlines of your own

An outline is a vector shape you make yourself, such as a shape of a vector drawing, from plain arrays as FreeType's FT_Outline holds one. stroke-outline turns it into the outline of its stroke, and render-outline rasterizes it into coverage, through FreeType's stroker and anti-aliasing rasterizer:

(ft2:with-library (lib)
  (let ((tick (ft2:make-outline '(2 5  4 3  8 7)                    ; x, y of each point
                                (list ft2:+on-curve+ ft2:+on-curve+ ft2:+on-curve+)
                                '(2))))                            ; each contour's last point
    (ft2:with-stroker (stroker lib :radius 1 :line-cap :round :line-join :round)
      (let ((stroke (ft2:stroke-outline tick stroker :open t)))    ; capped, not closed
        (ft2:render-outline lib stroke 10 10)))))
;; => #2A((0 0 0 0 0 0 0 0 0 0)
;;        (0 0 0 0 0 0 0 0 0 0)
;;        (0 0 0 0 0 0 21 194 179 0)
;;        (0 0 0 0 0 21 211 255 194 0)
;;        (0 178 195 21 22 212 255 210 21 0)
;;        (0 195 255 212 213 255 210 21 0 0)
;;        (0 21 211 255 255 210 21 0 0 0)
;;        (0 0 21 195 195 21 0 0 0 0) …)

An outline is plain Lisp data: it is never destroyed, and threads may share it. Each call copies it into memory FreeType allocates and frees that memory before it returns.

make-outline points tags contour-ends → outline

  • points: a sequence of reals, two for each point, x then y, in pixels with y growing up, as glyph outlines are. They are kept as single-floats, which hold FreeType's 26.6 coordinates exactly up to 262144 pixels.
  • tags: one for each point. +on-curve+ (1) marks a point the outline passes through, +conic-control+ (0) the control point of a conic, or quadratic Bézier, curve, and +cubic-control+ (2) each of the two control points of a cubic one. These are FreeType's FT_CURVE_TAG_… values. Two conic control points in a row meet on the curve midway between them, as in TrueType glyphs.
  • contour-ends: the index of each contour's last point, in increasing order, the last that of the last point.

The sequences are copied. An outline FreeType cannot use signals a freetype-error named :invalid-outline that says what is wrong: cubic control points come in pairs, followed by a point on the curve or by the end of the contour; a contour does not start with one; coordinates are within 8388608 (2^23) pixels of 0, so that FreeType's 26.6 numbers fit 32 bits; an outline holds at most 32767 points and contours.

(ft2:make-outline '(0 0 1 1 2 2) '(1 2 1) '(2))
;; freetype-error: The outline cannot be made: contour 0 is malformed: its
;; cubic control point 1 is not one of a pair.
;; Give two coordinates and a tag for each point, and each contour's last point.

outline-points, outline-tags and outline-contour-ends return the arrays, a (simple-array single-float (*)), a (simple-array (unsigned-byte 8) (*)) and a (simple-array (unsigned-byte 16) (*)); do not modify them. outline-p tests for an outline.

outline-control-box outline → xmin, ymin, xmax, ymax

The box around every point, control points included, in the outline's pixels; all zero for an outline of no points.

render-outline library outline width height &key (x 0) (y 0) (fill-rule :nonzero) → coverage

Rasterizes outline with FreeType's anti-aliasing rasterizer and returns how much of each pixel it covers: a 2D (unsigned-byte 8) array of height rows of width pixels, row 0 at the top, from 0 for none to 255 for all, as bitmap-data of a :gray bitmap. The pixels are those of the width by height box whose bottom left corner is at x, y in the outline's pixels; what lies outside is left out.

  • fill-rule: :nonzero fills where the contours wind around in total any number of times, which is right for glyphs and strokes whatever their direction; :even-odd where they wind around an odd number of times, so a contour inside another is a hole whichever way it runs. The two make up the type fill-rule.
  • width and height are at most 8388608 (2^23) pixels, the type bitmap-dimension, and x and y within as many pixels of 0, the type outline-coordinate, as an outline's coordinates are; others are a type-error.
  • Coverage is the exact area of each pixel covered, without gamma. FreeType draws curves as short lines, which cut inside them by a few hundredths of a pixel on average: a circle of radius 4 pixels loses about 2.6 % of its area, one of radius 32 about 0.3 %.
(ft2:render-outline lib (ft2:make-outline '(1 1 3 1 3 3 1 3) '(1 1 1 1) '(3)) 4 4)
;; => #2A((0 0 0 0) (0 255 255 0) (0 255 255 0) (0 0 0 0))

stroke-outline outline stroker &key open → outline

Returns the outline of the stroke stroker draws along outline, with its radius, caps, joins and miter limit; see Strokers. The radius is in the outline's pixels. Fill the result with the nonzero rule, render-outline's default: where a stroke turns sharply, its contours overlap.

  • open: which contours are open paths, ended with caps, rather than closed ones, joined where they close: nil for none, t for all, or a sequence with a generalized boolean for each contour.
  • A closed contour's stroke is two contours, its outer and its inner border; an open one's is one.
  • A contour of one point, or of points all in one place, makes no stroke, as FreeType's stroker makes none for it. To draw a dot there, as SVG's round caps do, add a circle yourself.
  • A stroke with more points than an outline holds signals :array-too-large.

Examples

Flattening to an SVG path

(defun outline->svg-path (contours &key (height 0))
  "Return an SVG path string for CONTOURS, flipped to SVG's Y-down axis
around HEIGHT."
  (with-output-to-string (s)
    (flet ((pt (x y) (format s " ~,3F ~,3F" x (- height y))))
      (dolist (contour contours)
        (dolist (segment contour)
          (destructuring-bind (kind &rest xy) segment
            (write-string (ecase kind
                            (:move-to "M") (:line-to "L")
                            (:conic-to "Q") (:cubic-to "C"))
                          s)
            (loop for (x y) on xy by #'cddr do (pt x y))))
        (write-string " Z " s)))))

(ft2:load-char face #\g :flags '(:no-scale))
(outline->svg-path (ft2:glyph-outline face) :height (ft2:face-ascender face))

Converting quadratics to cubics

(defun conic->cubic (x0 y0 cx cy x y)
  "Return the cubic control points of the quadratic from (X0, Y0) through
control (CX, CY) to (X, Y)."
  (list :cubic-to
        (+ x0 (* 2/3 (- cx x0))) (+ y0 (* 2/3 (- cy y0)))
        (+ x  (* 2/3 (- cx x)))  (+ y  (* 2/3 (- cy y)))
        x y))

Because the coordinates are exact rationals, conversions like this lose no precision.