Common Lisp bindings for FreeType 2: faces, glyphs, bitmaps, outlines, strokers and variable fonts.
  • Common Lisp 97.6%
  • Shell 1.3%
  • Python 1.1%
Find a file
dxtr 95f20f08c2
All checks were successful
CI / windows-dll (push) Successful in 57s
CI / test (push) Successful in 1m7s
Move the pins on the workspace's libraries
To the latest commit of cl-ffi, whose allocate boxes no pointer, of
Pyssel's step 25.
2026-10-19 21:30:55 +02:00
.forgejo/workflows Run on SBCL only 2026-10-17 04:34:54 +02:00
native Carry a FreeType DLL for Windows 2026-10-11 19:01:30 +02:00
src Rename the type BITMAP-SIZE to BITMAP-DIMENSION 2026-10-18 16:27:01 +02:00
tests Rename the type BITMAP-SIZE to BITMAP-DIMENSION 2026-10-18 16:27:01 +02:00
.gitattributes Keep LF line endings in every checkout 2026-10-16 15:49:09 +02:00
.gitignore Pin the dependencies with stift in place of qlot 2026-10-16 10:53:49 +02:00
cl-ft2.asd Signal FREETYPE-ERROR when FreeType cannot be loaded 2026-10-18 15:46:00 +02:00
CLAUDE.md Declare the arguments' full domain types 2026-10-18 16:22:53 +02:00
LICENSE Initial implementation of cl-ft2 2026-10-07 17:10:27 +02:00
README.org Rename the type BITMAP-SIZE to BITMAP-DIMENSION 2026-10-18 16:27:01 +02:00
stiftfile Move the pins on the workspace's libraries 2026-10-19 21:30:55 +02:00

cl-ft2

Common Lisp bindings for FreeType 2.

cl-ft2 comes in two layers:

  • cl-ft2: a Lisp-idiomatic API. It returns Lisp values (strings, keywords, integers, exact rationals), copies bitmaps into Lisp arrays, and turns every FreeType error into a freetype-error condition.
  • cl-ft2/ffi: the raw layer underneath, on cl-ffi, a direct transcription of the covered part of the C API (FT_Load_Glyph → ft-load-glyph, FT_FaceRec → (:struct ft-face-rec), …). It does no error checking and no memory management.

Only the libfreetype shared library (2.13.1 or newer) is needed at run time. The struct layouts are written by hand against the public headers using cl-ffi's C-sized types, so no C compiler is needed to build.

On Windows there is nothing to install: cl-ft2 carries its own native/windows-x64/freetype.dll and loads it by its full path, so no other freetype.dll on PATH gets in the way. An application saved from an image, without cl-ft2's source tree, loads freetype.dll by name instead: ship the DLL next to the executable. native/build-windows.sh rebuilds the DLL from source, reproducibly, and native/build-windows.sh --check verifies the committed one. That build has no PNG glyph bitmaps (sbix, CBDT colour emoji), bzip2-compressed fonts or WOFF2. When you ship it, include native/windows-x64/FTL.TXT: FreeType's licence asks for credit in your documentation.

Usage

(asdf:load-system :cl-ft2)

(defpackage #:example
  (:use #:cl)
  (:local-nicknames (#:ft2 #:cl-ft2)))
(in-package #:example)

(ft2:with-library (lib)
  (ft2:with-face (face lib "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
    (setf (ft2:face-size face) 32)          ; pixels per EM
    (let ((bitmap (ft2:char-bitmap face #\A)))   ; load, render and copy out
      (ft2:bitmap-data bitmap))))                ; => #2A((0 0 ...) ...), (unsigned-byte 8)

The examples here use ft2 as a local nickname for cl-ft2, as above. char-bitmap takes :mode (:normal, :mono, :lcd, :lcd-v, :sdf). glyph-index-bitmap does the same for a glyph index, such as one that shaping produced. A bitmap's left and top place it relative to the pen position on the baseline.

Glyphs step by step

Each face has one glyph slot. load-char and load-glyph load a glyph into it, and the readers report on the glyph loaded last:

(ft2:load-char face #\A)
(ft2:glyph-metrics face)                 ; => #S(GLYPH-METRICS :WIDTH 22 :HEIGHT 23 ...)
(ft2:glyph-advance face)                 ; => 22, 0
(ft2:render-glyph face :mode :mono)
(ft2:glyph-bitmap face)                  ; => #S(BITMAP :PIXEL-MODE :MONO ...)

;; Vector outlines, as contours of segments.
(ft2:load-char face #\O)
(ft2:glyph-outline face)                 ; => (((:MOVE-TO 807/64 20) (:CONIC-TO ...) ...) ...)

;; Kerning between two glyphs.
(ft2:kerning face (ft2:char-index face #\A) (ft2:char-index face #\V))
                                         ; => -2, 0

Glyph objects and strokers

;; Draw an outlined "A": keep a copy of the glyph, stroke it, render it.
(ft2:load-char face #\A)
(ft2:with-glyph (glyph face)                 ; a copy, independent of the face's slot
  (ft2:with-stroker (stroker lib :radius 3/2 :line-join :round)
    (ft2:stroke-glyph glyph stroker)         ; :border :both, :outside or :inside
    (ft2:glyph-bitmap (ft2:render-glyph glyph))))

The glyph-… functions (glyph-index, glyph-format, glyph-metrics, glyph-advance, glyph-linear-advance, glyph-outline, glyph-control-box, glyph-bitmap, render-glyph, copy-glyph) accept either a face, meaning the glyph in its glyph slot, or a glyph object. The glyph-index-… functions (glyph-index-bitmap, glyph-index-advance, glyph-index-name) take a face and a glyph index instead, and leave the glyph slot alone.

Outlines of your own

An outline is a vector shape of your own, made from plain arrays as FreeType's FT_Outline holds one: two coordinates for each point, in pixels with y growing up, a tag for each point, and the index of each contour's last point. stroke-outline turns it into the outline of its stroke, and render-outline rasterizes it into coverage:

(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 ...) ...), 10 by 10
  • A tag is +on-curve+ for a point the outline passes through, +conic-control+ for the control point of a conic (quadratic) curve, or +cubic-control+ for each of the two control points of a cubic one; they are FreeType's values, 1, 0 and 2. make-outline copies the sequences it is given, and signals a freetype-error named :invalid-outline that says what is wrong with one FreeType cannot use.
  • stroke-outline strokes with the stroker's radius, caps, joins and miter limit, its radius in the outline's pixels. :open says which contours are open paths, capped at their ends, and which are closed: nil for none, t for all, or a sequence with a boolean for each contour. Fill the stroke with the nonzero rule.
  • render-outline returns a 2D array of (unsigned-byte 8) coverage, 0 to 255, rows top first, for the box of the width and height given whose bottom left corner is at :x, :y in the outline's pixels. :fill-rule is :nonzero, as for glyphs and strokes, or :even-odd. FreeType draws curves as short lines a few hundredths of a pixel inside them.
  • outline-points, outline-tags and outline-contour-ends read an outline back, and outline-control-box gives the box around its points.

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.

Variable fonts

(ft2:with-face (face lib "Inter.ttf")
  (ft2:face-variation-axes face)             ; => (#S(VAR-AXIS :TAG "wght" :MINIMUM 100 ...) ...)
  (ft2:face-named-instances face)            ; => (#S(NAMED-INSTANCE :NAME "Thin" ...) ...)
  (setf (ft2:face-axis-coordinate face "wght") 650)
  (setf (ft2:face-named-instance face) 2)          ; or (ft2:open-face lib path :instance 2)
  (setf (ft2:face-design-coordinates face) '())    ; back to the named instance
  (setf (ft2:face-named-instance face) nil))       ; back to the font's defaults

Named instances are numbered from 0, in the order face-named-instances lists them, and nil means none, as in cl-harfbuzz.

Design coordinates are in each axis's own units (wght 100–900, …); normalized coordinates run from -1 to 1. Setting face-design-coordinates or face-normalized-coordinates clamps values to the axis range and fills unlisted axes with the font's defaults. The getters always report the coordinates actually in effect. FreeType's own getters can answer stale coordinates after a reset to the font's defaults, so there, with no named instance selected and no :variation flag, cl-ft2 answers the defaults itself. That FreeType quirk is handled by coordinates-in-effect in src/variation.lisp, whose comment explains it, and the test coordinates-after-a-reset covers it.

A FreeType bug that cl-ft2 does not work around: once a face has gone from a named instance whose coordinates were changed back to the font's defaults, selecting that named instance again, or setting its design coordinates, does nothing. Glyphs go on loading at the defaults, and the getters report the defaults. With the font of cl-ft2's tests, tests/fonts/cl-ft2-test-variable.ttf:

(setf (ft2:face-named-instance face) 2)            ; Black, wght 900
(setf (ft2:face-axis-coordinate face "wdth") 125)  ; changed
(setf (ft2:face-named-instance face) nil)          ; back to the defaults
(setf (ft2:face-named-instance face) 2)            ; Black again: no effect
(ft2:face-design-coordinates face)                 ; => (400 100), not (900 100)

Selecting another named instance, or setting other coordinates, before going back to it avoids the bug, as does opening the face again. It was seen with FreeType 2.14.3, and the code responsible is the same in 2.13.1, the oldest FreeType cl-ft2 takes, and in FreeType's development sources of October 2026: when it is asked for no instance, FT_Set_Named_Instance fills its copy of the design coordinates from the instance the face still records, so a later request for that instance's coordinates finds nothing changed and leaves the defaults in force.

Setting state

Everything about a face that can be read back is set with setf: its size, strike, transform, charmap, variation coordinates and named instance.

(setf (ft2:face-size face) 21/2)                    ; or '(10 20); points: pt * dpi / 72
(setf (ft2:face-transform-matrix face) '(1 1/5 0 1)) ; oblique
(setf (ft2:face-transform-delta face) '(1/4 0))     ; subpixel offset
(setf (ft2:face-charmap face) :apple-roman)

A two-dimensional place, such as face-size, reads as two values and is set from a number or a (x y) list.

Units

FreeType's 26.6 and 16.16 fixed-point numbers are turned into exact rationals: 807/64 is 12.609375 pixels. Face-wide metrics (face-ascender, face-bounding-box, …) are in font units. Glyph metrics, advances and outlines are in pixels, or in font units if the glyph was loaded with :no-scale.

Lifetimes

Libraries, faces, glyphs and strokers are destroyed explicitly, with destroy-library, destroy-face, destroy-glyph and destroy-stroker, or with the with- macros. destroy-library first destroys every face, glyph and stroker created in it. A glyph does not depend on the face it was copied from. An outline is plain Lisp data and needs no destroying. Using a destroyed object signals a freetype-error named :invalid-library-handle, :invalid-face-handle, or :invalid-handle for glyphs and strokers. It never touches freed memory. FreeType objects are not thread-safe, so use one library per thread or serialise access to it.

Errors

(handler-case (ft2:open-face lib "/no/such/font.ttf")
  (ft2:freetype-error (e)
    (values (ft2:freetype-error-name e)       ; => :CANNOT-OPEN-RESOURCE
            (princ-to-string e))))
;; => "Could not open the font /no/such/font.ttf: the file does not exist or cannot be read.
;;     Check the path and the file's permissions.
;;     (FreeType: FT_New_Face returned cannot-open-resource)"

The report says what went wrong in terms of the call, then what to do about it. freetype-error-message and freetype-error-hint return those two parts. freetype-error-name classifies the error with a keyword. freetype-error-function names the FreeType function that failed, or is nil when cl-ft2 caught the problem itself: a destroyed face, a glyph index out of range, an outline asked of a bitmap, a variation on a font without axes.

Arguments of the wrong type, such as an unknown load flag, render mode or encoding, or a glyph where a face belongs, signal a standard type-error before anything reaches FreeType. The types of the keyword arguments, such as render-mode and line-join, are exported, as the API overview lists them, so a program can test a value with typep.

API overview

Errors

freetype-error, freetype-error-message, freetype-error-hint, freetype-error-name, freetype-error-code, freetype-error-function.

Library

make-library, destroy-library, with-library, library-live-p, library-version.

Faces

open-face (from a filename, a pathname or a vector of octets, the type font-source), destroy-face, with-face, face-live-p, face-library, face-num-faces, face-index, face-num-glyphs, face-family-name, face-style-name, face-postscript-name, face-font-format, face-flags, face-style-flags, face-scalable-p, face-fixed-width-p, face-has-kerning-p, face-has-glyph-names-p, face-italic-p, face-bold-p, face-units-per-em, face-ascender, face-descender, face-height, face-max-advance-width, face-max-advance-height, face-underline-position, face-underline-thickness, face-bounding-box.

Sizes and transforms

face-size (pixels per EM, a real or a (width height) list, the type face-size-designator), face-strike (an index into face-strikes → strike), face-size-metrics (→ size-metrics), face-transform-matrix (a transform-matrix, (xx xy yx yy)), face-transform-delta (a transform-delta, (x y)).

Character maps

face-charmaps, face-charmap (→ charmap; setf it to an encoding keyword, the type encoding, or a charmap), char-index (of a character or a code, the type character-or-code), map-characters, do-characters, glyph-index-name, name-index.

Glyphs

load-glyph, load-char (their :target is a hinting-target), glyph-index, glyph-format, glyph-metrics (→ glyph-metrics), glyph-advance, glyph-linear-advance, glyph-index-advance, kerning (its :mode is a kerning-mode).

Rendering and outlines

char-bitmap, glyph-index-bitmap, render-glyph (their :mode is a render-mode), glyph-bitmap (→ bitmap, whose pixel mode is a pixel-mode and whose data a pixel-data), glyph-outline, glyph-control-box.

Glyph objects

copy-glyph, destroy-glyph, with-glyph, glyph-live-p, glyph-library, transform-glyph (its :matrix is a transform-matrix and its :delta a transform-delta, as render-glyph's :origin is).

Strokers

make-stroker (its :line-cap is a line-cap and its :line-join a line-join), destroy-stroker, with-stroker, stroker-live-p, stroker-library, stroker-radius, stroker-line-cap, stroker-line-join, stroker-miter-limit, stroke-glyph (its :border is a stroke-border).

Outlines of your own

make-outline (→ outline), outline-p, outline-points, outline-tags, outline-contour-ends, outline-control-box, +on-curve+, +conic-control+, +cubic-control+, stroke-outline, render-outline (its width and height are each a bitmap-dimension, up to 2^23 pixels, its :x and :y each an outline-coordinate, within 2^23 pixels of 0, and its :fill-rule a fill-rule).

Font variations

face-variable-p, face-variation-axes (→ var-axis), face-named-instances (→ named-instance), face-design-coordinates, face-axis-coordinate, face-normalized-coordinates, face-named-instance, face-default-named-instance. The coordinates, the axis coordinate and the named instance are set with setf.

Escape hatch and face dependents

library-handle and face-handle return the raw FT_Library and FT_Face pointers so you can call cl-ft2/ffi functions on objects managed by cl-ft2.

Other libraries can build objects on a face's FT_Face. HarfBuzz's hb-ft is the motivating case, used by cl-harfbuzz/ft2. To keep such an object safe, register it with register-face-dependent and give its class methods on two generic functions:

  • release-face-dependent (required): called by destroy-face, and so by destroy-library, before the FT_Face is destroyed.
  • face-changed (optional): called after the face's size, transform or variation coordinates change.

unregister-face-dependent stops both, e.g. when the object is released first.

Not covered

Some FreeType features are left to other libraries:

  • Text shaping and GPOS kerning: use HarfBuzz. kerning reads only the legacy kern table.
  • OpenType tables and color glyph layers (COLR/CPAL): use HarfBuzz's typed OpenType APIs. COLRv0 glyphs already render to :bgra bitmaps here if you load them with :color.
  • Bidi, script runs and line breaking: use ICU.

FreeType's cache subsystem and custom FT_Stream input are not bound. Caching rendered bitmap objects in Lisp is simpler, and open-face reads fonts from octet vectors.

Development

Dependencies are pinned in the stiftfile with stift, and tests use parachute.

stift test

The tests need libfreetype and DejaVu Sans (Debian/Ubuntu: libfreetype6 fonts-dejavu-core; Windows: DejaVu Sans only). To use DejaVu Sans from another location, set CL_FT2_TEST_FONT to its path. The variable-font tests use tests/fonts/cl-ft2-test-variable.ttf, a tiny font with exactly specified geometry. It is committed to the repo; to rebuild it after changing tests/fonts/make-test-variable-font.py, run that script, which needs fontTools (pip install fonttools).

License

MIT. See LICENSE.