- Common Lisp 100%
|
All checks were successful
CI / test (push) Successful in 1m23s
A glyph atlas packs each font's glyphs into an atlas for each scale, rather than one atlas for every scale, so that a scale's glyphs can go: cl-atlas frees no single glyph. HOLD-SCALE and RELEASE-SCALE count the views that draw at a scale, and the last release frees every font's atlas at it, as Godot 4.5 frees an oversampling level's glyphs with its last reference; a scale never held keeps its glyphs as before. Each scale is a level with a cache key of its own, under which fonts keep their atlas at it, forgotten with the last hold, or once the collector takes the glyph atlas. SCALE-HOLDS counts a scale's holds. FONT-ATLAS takes :SCALE, and an atlas's data is the glyph atlas's own: ATLAS-FONT and ATLAS-SCALE read the font and scale it holds glyphs of. A glyph's key in its atlas is one fixnum, so finding it needs no key on the stack, and finding a scale allocates nothing. The glyph atlas rasterizes with the raster backend's new RASTERIZE-GLYPH-AFRESH, which draws as RASTERIZE-GLYPH does into an array the rasterizer does not keep, so that the atlas holds the only copy of its images, and they go with it. For Pyssel's step 25, as Kim decided on 2026-10-19. |
||
|---|---|---|
| .forgejo/workflows | ||
| atlas | ||
| raster | ||
| sdf | ||
| src | ||
| tests | ||
| .gitattributes | ||
| .gitignore | ||
| cl-textrender.asd | ||
| CLAUDE.md | ||
| LICENSE | ||
| README.org | ||
| stiftfile | ||
cl-textrender
Text layout for Common Lisp, built on cl-icu and cl-harfbuzz: paragraphs, bidirectional text, line breaking and shaping, turned into lines of positioned glyphs, with hit testing for carets and selections.
Layout doesn't rasterize anything. It produces glyph IDs and unhinted positions in resolution-independent units, ready for a renderer that draws from outlines (MSDF, Slug, vector output) or from a glyph atlas. FreeType is not needed: HarfBuzz reads TrueType and OpenType (CFF) fonts itself.
Usage
(asdf:load-system :cl-textrender)
(defpackage #:example
(:use #:cl)
(:local-nicknames (#:tr #:cl-textrender)))
(in-package #:example)
(tr:with-font (font "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
:size 16) ; 16 layout units per em
(let ((layout (tr:layout-text "Hello שלום world" font
:width 120 :align :start)))
;; Draw: every glyph, already placed. Here "drawing" prints; your
;; renderer takes the glyph's font, ID and position the same way.
(tr:do-glyphs (g layout)
(format t "glyph ~D at ~,2F, ~,2F~%"
(tr:glyph-id g) (tr:glyph-x g) (tr:glyph-y g)))
;; Edit: carets, clicks and selections.
(print (list (multiple-value-list (tr:caret-position layout 6))
; => (81.046875 0.0 18.625): x, top, bottom
(tr:hit-test layout 40.0 10.0) ; => 5, a text index
(tr:selection-rectangles layout 3 9) ; => a list of RECTs
(tr:next-caret-index layout 6))))) ; => 7, the next grapheme
The package has no global nickname, which would take a name from every
package in the image: give it a local nickname in your own, as above. The
examples below call it tr.
Faces and fonts
A face is a typeface: one face of a font file, by :index in a
collection, with the :variations of a variable font it is drawn with,
such as '(("wght" . 700)). A font is a face at a size, in layout units
per em. make-face takes a file name, pathname or octet vector, and
face-font gives the face at a size, the same font each time for one
size. make-font makes a font with a face of its own, for when one size
is all you need.
(let* ((face (tr:make-face "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"))
(body (tr:face-font face 16))
(title (tr:face-font face 32)))
(list (tr:font-line-height body) (tr:font-line-height title)))
;; => (18.625 37.25)
font-glyph-id gives the glyph ID a font draws a character with, and
font-glyph-advance a glyph's advance in layout units. Use them for
single glyphs such as a caret or a bullet, and layout-text for text.
A font's glyph IDs run from 0 below font-glyph-count. Every function
that takes a glyph ID draws any other ID as the font's .notdef, glyph
0, as a font draws a character it lacks; font-glyph-or-notdef gives the
ID that is drawn.
Faces and fonts hold foreign objects, which are freed once nothing holds
them, as Godot frees a font once nothing refers to it. A font holds its
face, a face the fonts it gave, and a layout its fonts, so no font is freed
while text laid out with it is around. destroy-face and destroy-font
free them at once, and with-face and with-font when their body is left;
destroy-font frees the face make-font made with it too.
Renderers keep what they make from a face or font with it, as Godot's
fonts keep their glyph caches, so that it goes with the font:
face-cache and font-cache return what one keeps under its cache key,
from make-cache-key, made the first time by a function, which may also
return a function that releases its foreign or GPU parts when the face or
font goes. A renderer that goes first forgets its key, so that faces and
fonts that outlive it keep nothing of it, nor the renderer, which those
functions may refer to:
(let ((key (tr:make-cache-key)))
(tr:font-cache font key (lambda () (make-hash-table))) ; made once, kept by FONT
(tr:forget-cache-key key)) ; FONT forgets it
The raster backend, the glyph atlas and the distance field atlas below keep theirs so, under keys forgotten once the collector takes them, and finding what was kept allocates nothing.
Styled text and font fallback
Wherever a font is taken, a list of fonts works too. The first is the primary, and later ones are fallbacks for characters it lacks:
(tr:layout-text text (list latin-font cjk-font emoji-font)
:spans '((0 5 :data :red) ; anything, passed to the glyphs
(6 12 :font bold-font) ; a font or a fallback list
(13 20 :features ("smcp" "-liga")))) ; OpenType features
- Fallback. Each grapheme is drawn from the first font that has glyphs
for all of it, so a letter is never split from its accents. A grapheme
stays in the previous grapheme's font when that font has it, so spaces in
CJK text don't switch back to the primary. Joiners and variation selectors
don't count, and characters no font has show as the primary's
.notdef. - Spans.
(start end &key font features data). Later spans override earlier ones where they overlap, key by key. A span'sdata(a color, say) is on every glyph it covers (glyph-data) and on its runs (run-data). - Mixed metrics. Each glyph records the font it actually came from
(
glyph-font), and runs split wherever the font or style changes. Each line is as tall as the largest fonts on it:line-ascentandline-descentgive the values used.
Outlines
For renderers that draw from outlines (MSDF, Slug), glyph-outline returns a
glyph's outline. It is in layout units of the font's size, in font
orientation (y up), relative to the glyph's origin on the baseline. Make a
font of size 1 to get outlines in ems.
- Segments. Contours are closed lists of segments with explicit start
points:
(:line x0 y0 x1 y1),(:quadratic x0 y0 cx cy x1 y1)and(:cubic x0 y0 c1x c1y c2x c2y x1 y1). TrueType fonts give quadratics; CFF (.otf) fonts give cubics. - Bounds.
outline-xmin,-ymin,-xmaxand-ymaxbound every point, control points included. - Quadratic only.
:quadratic tapproximates cubics with quadratics, within:tolerancelayout units (a thousandth of an em by default). - Orientation. TrueType glyphs run clockwise (outer contours; holes
the other way) and CFF glyphs counterclockwise.
outline-orientationtells which, fromoutline-signed-area, which is exact for all three segment kinds.orient-outlinereturns the outline in the direction you choose, for an MSDF generator that needs a consistent sign. - Packed for Slug.
outline-quadraticspacks an outline into one single-float vector with six floats per curve (x0 y0 cx cy x1 y1). Lines become quadratics with the control point at their middle.
(tr:cached-quadratics font glyph-id) ; => #(x0 y0 cx cy x1 y1 ...)
(tr:cached-outline font glyph-id) ; => OUTLINE, for MSDF
cached-outline and cached-quadratics compute each glyph once: the font
keeps them, and frees them with itself. Finding one again allocates
nothing, so a renderer can look glyphs up every frame, from any thread.
Units and axes
- Units. A font's size is in layout units per em. Every position a layout returns is a single-float in those units: pixels for UI text, world units for text in a scene.
- Y axis.
:y-axis :down(the default) is screen space: the first line's top is at 0 and lines go down, with the baseline at the font's ascent.:y-axis :upis world space: every y coordinate is negated, so the text hangs below the origin. Hit testing takes coordinates in the same convention, and arect'syis always its smallest coordinate. - Glyph outlines are always in font orientation (y up) relative to the
glyph's origin on the baseline. An outline point
(ox, oy)goes at(glyph-x + ox, glyph-y - oy)in a:downlayout and at(glyph-x + ox, glyph-y + oy)in an:uplayout. A glyph whoseglyph-rotated-pis true (sideways text in a vertical layout) is turned 90° clockwise: the point goes at(glyph-x + oy, glyph-y + ox)in a:downlayout and at(glyph-x + oy, glyph-y - ox)in an:uplayout.
Layout
layout-text takes:
:width: the longest a line may be (the column height, in vertical text). Lines wrap at it;nilkeeps each paragraph on one line, as long as it is.:wrap:nilkeeps each paragraph on one line within:width, cut short where it is longer, as below.:align::start,:end,:left,:right,:centeror:justify.:startand:endfollow each paragraph's direction.:direction::auto,:ltror:rtl.:line-spacing: a multiplier, with the extra space split above and below each line.:tab-stops,:max-lines,:ellipsisand:orientation, below.:spans, above, and:y-axis.:into: a layout made before, laid out again in place and returned, its lines, runs and glyphs reused, for text that changes every frame, as a timer's. A simple line, without spans or tabs, left to right in one script beside digits and punctuation, each character a grapheme with a glyph in the first font, is laid out again so without allocating.
It works one paragraph at a time:
- Bidi. Paragraphs are split at newlines, and each resolves its own direction.
- Line breaking. Lines break at ICU's break opportunities, or between
graphemes when a single word is wider than the line. Trailing spaces hang
past the margin. With
:wrap nil, lines break only where a break is mandatory. - Reordering. Each line is reordered on its own (UAX #9).
- Shaping. Each line is cut into runs of one direction and one script, and each run is shaped with the whole paragraph as context.
A layout has lines, top to bottom. Each line has its text range, its
paragraph direction, its baseline, and runs in visual order. Its
line-box is the rectangle of its share of the layout: the layout's width
by the line's height. Its line-content is the rectangle its visible text
takes up: the same height, but only from its first glyph to its last,
trailing spaces left out. Each run holds glyphs left to right, and its
run-box is the rectangle the run takes up. All three are rect objects, whose
x and y are their smallest coordinates.
Tabs, justification and ellipsis
- Tabs.
:tab-stopsis an interval, or a list of increasing positions along the line, after which stops continue every eight spaces of the first font. The default is every eight spaces. Stops are measured from the line's start edge, so from the right in right-to-left paragraphs. A tab is drawn as a space glyph. - Justification.
:align :justifywidens the spaces of every line except a paragraph's last line and lines ended by a hard break, so they fill:widthexactly. Lines without spaces stay at the start. - Ellipsis.
:max-lineslimits the number of lines; paragraphs count as lines. Text that doesn't fit is left out, and the last kept line ends in:ellipsis("…"by default;nilfor none). The line is cut at a grapheme boundary so the ellipsis fits within:width, and the ellipsis goes at the line's logical end: the right in left-to-right text, the left in right-to-left text. It takes the style of the last character kept, and each of its graphemes comes from that style's fallback list, as text does.layout-truncated-pandline-truncated-preport it. The ellipsis is a run, or one run per font, whosestartequals itsend. It has no caret stops, and indexes in the hidden text map to the end of the last line. -
Cutting lines short.
:wrap nilkeeps each paragraph on one line, and cuts a line longer than:widthat the last grapheme boundary where it fits with the:ellipsisafter it, as a cell of a table or a tree cuts its text. Each paragraph is cut on its own, and with:ellipsis nila line keeps all that fits.line-truncated-pis true for a line cut short, which covers only the text it shows: the text cut off belongs to no line, and a caret in it is at the cut, before the ellipsis. Wrapping with:max-lines 1cuts at a word instead:(flet ((shown (layout) ; the text of its first line (let ((line (aref (tr:layout-lines layout) 0))) (subseq (tr:layout-string layout) (tr:line-start line) (tr:line-end line))))) (list (shown (tr:layout-text "Node number 12" font :width 100 :wrap nil)) (shown (tr:layout-text "Node number 12" font :width 100 :max-lines 1)))) ;; => ("Node num" "Node"), each drawn with "…" after it
Vertical text
:orientation :vertical sets text in columns, read top to bottom, with
columns running right to left, as in traditional Chinese and Japanese.
- Upright or sideways. Each character's Unicode
Vertical_Orientation(UTR #50) decides how it is set. CJK and other upright characters stand upright, and are shaped top to bottom so the font's vertical forms (itsvertfeature) apply. The rest (Latin, Arabic, …) are turned 90° clockwise and markedglyph-rotated-p. - Lines are columns.
layout-widthis the total width of the columns andlayout-heightthe column length. A column'sline-boxis the whole column, itsline-contentthe stretch of it its text takes up, and itsbaselinethe x of its central baseline. - The same features apply.
:widthis the column height, and:alignworks along it (:startis the top). Hit testing, selections and caret movement work along the columns.
Hit testing
- Caret stops are grapheme boundaries, so a caret never splits a letter from its accents, or an emoji sequence.
- Carets in ligatures are spread evenly across the ligature.
caret-positiondraws the caret at index i at the leading edge of character i: its left edge in left-to-right text, its right edge in right-to-left text, its top in vertical text. It returns the position along the line and the line's two edges across it.caret-segmentreturns the same caret as a segmentx0 y0 x1 y1.hit-testclamps to the nearest line and character. On a wrapped line, a point past the end stays on that line.selection-rectanglesreturns one rectangle per contiguous visual span, so selections across bidi runs come out in pieces.-
move-caretmoves the caret like an editor's arrow keys, with:left,:right,:upor:down.- Along a line, it moves visually. It steps one grapheme at a time through bidi text in screen order, and continues onto the next or previous line at a line's end.
- Across lines, it keeps a goal. It goes to the caret stop on the adjacent line nearest a goal position, and returns the goal as a second value. Pass it back on the next move, so moving through a short line doesn't lose the column.
- In vertical text the axes swap: up and down move along a column, left and right between columns.
- Logical moves:
next-caret-indexandprevious-caret-indexmove in logical order instead.
Raster backend
The optional cl-textrender/raster system draws layouts on the CPU with
FreeType, through cl-ft2. It's useful
for debugging, images, and small text where outline-based GPU rendering looks
soft.
(asdf:load-system :cl-textrender/raster)
(let ((r (cl-textrender/raster:make-rasterizer))) ; :hinting :light or :none
(cl-textrender/raster:render-layout r layout :scale 2) ; => 2D octet coverage image
(cl-textrender/raster:map-layout-glyphs
(lambda (coverage x y glyph) ; composite yourself, e.g. in the span's color:
(blend coverage x y (tr:glyph-data glyph))) ; BLEND is yours
r layout :scale 2 :x 10 :y 20))
Coverage is a 2D array of octets indexed by row, then column.
map-layout-glyphs passes each glyph's coverage with the whole pixel column
and row of its top-left corner, for a layout drawn at :scale pixels per
layout unit with its top left at :x, :y. rasterize-glyph draws one glyph
and returns its coverage, plus the offsets of its top-left corner from the
glyph's origin; rasterize-glyph-afresh draws it into a new array the
rasterizer does not keep, for a cache of your own.
- Hinting. FreeType's light hinting aligns glyphs to the pixel grid
vertically only, so the layout's unhinted advances still hold.
:noneturns hinting off. - Subpixel positioning. Each glyph is drawn at the nearest quarter of a pixel horizontally, and its bitmaps are cached per offset. Drawing a layout whose glyphs were drawn before allocates nothing.
- Orientation. Images and
map-layout-glyphspositions grow downwards from the layout's top, whether it was laid out:downor:up. Rotated glyphs in vertical text are drawn turned. - Kept with the font. Each face keeps a FreeType library and face of its own for the rasterizer, set to each glyph's size as it is drawn, and each font keeps its glyph images; both go with them, or with the rasterizer once the collector takes it. A rasterizer holds nothing foreign, so it needs no releasing.
- Threads. A rasterizer may be used from any number of threads at once. FreeType draws with a face from one thread at a time, so the glyphs of one face are drawn one at a time.
- Color glyphs. Color bitmap glyphs give their alpha as coverage.
Glyph atlas
The optional cl-textrender/atlas system is for renderers that draw text from
a texture: it rasterizes each glyph once, with cl-textrender/raster, and
packs it into a cl-atlas atlas of one
byte of coverage a texel, whose pages the renderer uploads. Each font has an
atlas of its own for each scale, which it keeps, as Godot's fonts keep their
glyph textures, and which goes with it, or with the last view that draws at
its scale.
(asdf:load-system :cl-textrender/atlas)
(let ((glyphs (cl-textrender/atlas:make-glyph-atlas)) ; :page-size, :hinting
(quads '()))
(cl-textrender/atlas:map-layout-glyphs
(lambda (region x y glyph) ; draw REGION of its page with its top left at X, Y
(declare (ignore glyph))
(push (list (cl-atlas:page-index (cl-atlas:region-page region)) x y) quads))
glyphs layout :scale 2 :x 10 :y 20)
(nreverse quads))
;; => ((0 13 27) (0 37 26)) for "Hi" in DejaVu Sans at size 16
map-layout-glyphs gives each inked glyph's region and the whole pixel where
the region's top left goes, for a layout drawn at :scale pixels per layout
unit with its top left at :x, :y; positions are those the raster backend's
map-layout-glyphs gives, a quarter of a pixel horizontally. glyph-region
looks up one glyph, and glyph-image-offset says where its image sits
relative to its origin. Once a layout's glyphs are packed, map-layout-glyphs
allocates nothing, so a renderer can call it every frame. font-atlas is a
font's cl-atlas atlas at a :scale, :page-size texels square (512 by
default): before drawing, upload what cl-atlas:take-page-changes reports
of the pages you draw from. atlas-font and atlas-scale tell what an
atlas holds, so a renderer holding one of its pages reaches the font, and
keeps it alive.
A view that draws at a scale holds it with hold-scale, and releases it
with release-scale when it draws at another or goes. Holds are counted,
and the last release frees every font's atlas at that scale, as Godot 4.5
frees an oversampling level's glyphs with its last reference, so a zoom no
view draws at any more keeps no glyphs:
(cl-textrender/atlas:hold-scale glyphs 2) ; a view starts drawing at 2
;; ...draw with :scale 2...
(cl-textrender/atlas:release-scale glyphs 2) ; => T: the scale's glyphs went
A glyph atlas may be used from any number of threads at once; release a scale while no thread asks for glyphs at it. What it makes is kept with the fonts it draws, and goes with them, with the last hold on its scale, or with the glyph atlas once the collector takes it, so it holds nothing foreign and needs no releasing.
Distance field atlas
The optional cl-textrender/sdf system is for renderers that draw text from
distance fields, which stay sharp over a range of sizes and give outlines,
glows and shadows cheaply: it makes each glyph into a multi-channel true
signed distance field (MTSDF) once, with
cl-sdf, and packs it into a cl-atlas
atlas of four bytes a texel, whose pages the renderer uploads as RGBA8
textures. Each face has an atlas of its own, which it keeps and which goes
with it.
(asdf:load-system :cl-textrender/sdf)
(let* ((fields (cl-textrender/sdf:make-field-atlas)) ; :page-size, :texels-per-em, :spread
(region (cl-textrender/sdf:glyph-region fields font (tr:font-glyph-id font #\A))))
(list (cl-atlas:region-width region) (cl-atlas:region-height region)
(multiple-value-list (cl-textrender/sdf:glyph-field-bounds region))))
;; => (38 40 (-0.2421875 -0.25 0.9453125 1.0)) for DejaVu Sans
Fields are made in ems, :texels-per-em texels to the em (32 by default), so
one field serves every size of a face: the fonts face-font gives of one face
share them. face-atlas is a face's cl-atlas atlas, :page-size texels
square (512 by default), and its atlas-data the face. glyph-field-bounds
gives the rectangle of ems a region covers, y up from the glyph's origin,
which reaches :spread ems (a quarter by default) beyond the outline on every
side. Red, green and blue hold the multi-channel distance, whose median draws
the glyph with sharp corners; alpha holds the true distance, for effects. Each
channel maps -spread ems to 0, the edge to 0.5 and +spread ems to 1, positive
inside.
To draw a layout, map-layout-fields gives each inked glyph's region and
the quad its field goes on, in pixels: the corner of the field's first
texel, the vector its rows run along and the vector its columns run down.
Draw the region's texture coordinates on that quad. Glyphs turned in
vertical text come with their quads turned, so they need no special case:
(let ((quads '()))
(cl-textrender/sdf:map-layout-fields
(lambda (region x y ux uy vx vy glyph) ; from X, Y along U and down V
(declare (ignore region glyph))
(push (list x y ux uy vx vy) quads))
fields layout :scale 2 :x 10 :y 20)
(nreverse quads))
;; => ((5.140625 17.703125 34.0 0.0 0.0 40.0)
;; (29.078125 16.703125 19.0 0.0 0.0 41.0)) for "Hi" in DejaVu Sans at size 16
Making a field takes a few milliseconds, so a renderer that must not stall
calls glyph-region on worker threads first: a field atlas may be used from
any number of threads at once, and fields for different glyphs are made in
parallel. Finding a field made before allocates nothing. What a field
atlas makes is kept with the faces it draws, and goes with them, or with the
field atlas once the collector takes it, so it needs no releasing.
Errors
(handler-case (tr:layout-text "abc" font :spans '((2 9 :data :red)))
(tr:textrender-error (e)
(princ-to-string e)))
;; => "The span (2 9 :DATA :RED) ends at 9, past the end of the text, which has 3 characters.
;; A span is (START END &key FONT FEATURES DATA), where START and END are
;; character indexes with 0 <= START <= END <= the length of the text."
cl-textrender signals textrender-error for every failure: a font file that
cannot be read, a destroyed face or font, a span or index outside the
text, an OpenType feature that cannot be parsed. A glyph ID a font does
not have is no failure: it is drawn as the font's .notdef. Errors from
the libraries underneath are reported in cl-textrender's terms, so you
never need to handle a cl-harfbuzz or cl-ft2 condition. The report says
what went wrong, then what to do; textrender-error-message and
textrender-error-hint return the two parts.
An argument of the wrong type, or a keyword argument that is not one of
the documented choices, signals a standard type-error as the function is
entered: every exported function declares its arguments' types, which SBCL
checks before the function runs. A constant of the wrong type is caught
sooner, as SBCL compiles the call: it warns that the constant conflicts
with the declared type.
Not yet covered
- Hyphenation.
- Justification without spaces. Inter-character justification (CJK) and kashida stretching (Arabic) are not included.
- Finer vertical-text details. Upright and transformed characters are
shaped top to bottom: a
Trcharacter the font has no vertical form for stands upright rather than turning. Short horizontal runs inside a column (tate-chū-yoko, such as two-digit numbers) are not set specially. - Color glyphs in the raster backend. Color bitmap glyphs draw as their alpha only.
Development
Dependencies are pinned in the stiftfile with
stift, and tests use
parachute.
stift test
The tests need HarfBuzz, ICU, FreeType, DejaVu Sans, URW Bookman and Droid
Sans Fallback (Debian/Ubuntu: libharfbuzz0b libicu-dev libfreetype6
fonts-dejavu-core fonts-urw-base35 fonts-droid-fallback). To use the fonts
from other locations, set CL_TEXTRENDER_TEST_FONT,
CL_TEXTRENDER_TEST_CFF_FONT and CL_TEXTRENDER_TEST_CJK_FONT to their
paths.
License
MIT. See LICENSE.