7 Laying Out Text
dxtr edited this page 2026-10-19 19:02:01 +02:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Laying Out Text

(tr:layout-text text font
                &key width (wrap t) (align :start) (direction :auto)
                     (line-spacing 1.0) (y-axis :down) spans
                     (orientation :horizontal) tab-stops max-lines
                     (ellipsis "…"))

layout-text lays out a string and returns a layout. font is a font, or a list of fonts to fall back on. The keyword arguments:

Argument Values Default Meaning
:width positive real or nil nil The longest a line may be, in layout units (the column height, in vertical text). nil keeps each paragraph on one line, as long as it is.
:wrap generalized boolean t Whether lines wrap at :width. With nil, each paragraph stays on one line, cut short where it is longer than :width; see Typography.
:align :start :end :left :right :center :justify :start Where each line sits within the width. :start and :end follow each paragraph's direction.
:direction :auto :ltr :rtl :auto The base direction. :auto lets each paragraph's first strong character decide.
:line-spacing positive real 1.0 A multiplier for the line height.
:y-axis :down :up :down Screen space or world space; see Coordinates and Units.
:spans list () Styles for ranges of the text; see Styled Text and Fallback.
:orientation :horizontal :vertical :horizontal Lines or columns; see Vertical Text.
:tab-stops positive real, list of increasing ones, or nil nil Where tabs go; see Typography.
:max-lines positive integer or nil nil The most lines to keep; see Typography.
:ellipsis string or nil "…" What ends the last line when text is left out, and a line cut short.

A value outside these choices signals a type-error.

In the examples, font is DejaVu Sans at size 16, as in the first one.

A first layout

(tr:with-font (font "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
                    :size 16)
  (let ((layout (tr:layout-text "The quick brown fox" font :width 80)))
    (map 'list (lambda (line)
                 (list (subseq (tr:layout-string layout)
                               (tr:line-start line)
                               (tr:line-end line))
                       (tr:line-baseline line)
                       (tr:rect-width (tr:line-content line))))
         (tr:layout-lines layout))))
;; => (("The quick " 14.8515625 77.64844)
;;     ("brown fox" 33.476563 78.88281))

The first line holds "The quick " with its trailing space; its content, not counting that space, is 77.65 wide. The layout is 80 wide, the :width, and 37.25 high, two lines of 18.625, so each line's box is 80 by 18.625.

Reading the result

The result is a tree of immutable structures (see Concepts):

  • layout-lines is a vector of lines, from the top.
  • line-runs is a vector of runs, in visual order along the line.
  • run-glyphs is a vector of glyphs, in visual order along the line.

Most renderers only need the glyphs. do-glyphs walks them all, line by line, without allocating:

(tr:do-glyphs (glyph layout)
  (draw-glyph (tr:glyph-font glyph) (tr:glyph-id glyph)
              (tr:glyph-x glyph) (tr:glyph-y glyph)
              (tr:glyph-data glyph)))   ; DRAW-GLYPH is yours

do-glyphs takes an optional result form, (do-glyphs (g layout result) ...), and return leaves it early. layout-glyphs returns the same glyphs in a fresh vector.

Layouts

Accessor What it is
layout-string The text that was laid out.
layout-font The font or font list layout-text was given.
layout-lines A vector of lines, in order.
layout-width, layout-height The layout's size in layout units.
layout-y-axis :down or :up.
layout-orientation :horizontal or :vertical.
layout-truncated-p True if text was left out: lines past :max-lines, or what lines cut short with :wrap nil cut off.

The width is :width when one was given, and otherwise the widest line. The height is the sum of the line heights. (In vertical text the two swap: see Vertical Text.)

Lines

Accessor What it is
line-start, line-end The range of text the line covers, including trailing whitespace and, on a paragraph's last line, the newline.
line-direction Its paragraph's direction, :ltr or :rtl.
line-runs A vector of runs, in visual order.
line-baseline The baseline's y.
line-box A rect: the line's share of the layout, the layout's width by the line's height.
line-content A rect: the part of the line box the visible content takes up, from the first glyph to the last, trailing whitespace left out.
line-ascent, line-descent The largest ascent and descent of the fonts on the line.
line-truncated-p True for a line cut short to fit an ellipsis. Such a line covers only the text it shows.

Runs

Accessor What it is
run-start, run-end The range of text the run covers. Equal for an ellipsis, which covers no text.
run-direction :ltr, :rtl, or :ttb for upright vertical text.
run-script The ISO 15924 script code, such as "Latn", "Hebr" or "Hani", or nil for an ellipsis.
run-font The font the run's glyphs come from.
run-data The :data of the run's style.
run-glyphs A vector of glyphs, in visual order along the line.
run-box A rect: the part of the line box the run takes up, trailing whitespace included.

Glyphs

Accessor What it is
glyph-id The glyph index in glyph-font.
glyph-font The font the glyph comes from: a fallback font, if the style's first font lacked it.
glyph-text-index The index of the first character the glyph was made from.
glyph-x, glyph-y Where the glyph's origin on the baseline goes, in layout units.
glyph-advance How far the glyph advances along the line, after shaping, tabs and justification.
glyph-data The :data of the glyph's style, such as a color.
glyph-rotated-p True for a glyph turned 90° clockwise in vertical text.

Paragraphs

Newlines split the text into paragraphs, each starting a new line. A text ending in a newline gets an empty last line, and an empty text has one empty line:

(defun lines-of (layout)
  "Return the text of each line of LAYOUT."
  (map 'list (lambda (line)
               (subseq (tr:layout-string layout)
                       (tr:line-start line) (tr:line-end line)))
       (tr:layout-lines layout)))

(lines-of (tr:layout-text (format nil "one~%~%three~%") font))
;; => ("one
;;     " "
;;     " "three
;;     " "")

Line feed, carriage return, CR LF, U+2029 and the other characters of bidi class B all end a paragraph. They draw no glyph.

Wrapping

With :width, each paragraph is broken into lines no longer than the width:

  • Break opportunities come from ICU's line breaking (UAX #14): after spaces, after hyphens, between CJK ideographs, and so on. Lines are filled greedily: each takes as much text as fits.
  • Trailing whitespace hangs. Spaces at the end of a line may go past the width. They belong to the line, but line-content leaves them out.
  • Long words break. A word wider than the line breaks between graphemes, at the last boundary that fits.
  • Nothing breaks a grapheme. A single grapheme wider than the line gets a line of its own.
  • Tabs count at their expanded width.

Without :width, paragraphs are not wrapped. With :wrap nil, they are not wrapped either, but a line longer than :width is cut short: see Typography.

Alignment

:align places each line's content within the width:

:align Left-to-right paragraph Right-to-left paragraph
:start left right
:end right left
:left left left
:right right right
:center centered centered
:justify filled edge to edge, last line at the start filled edge to edge, last line at the start
(tr:layout-text "The quick brown fox" font :width 80 :align :center)
;; rect-x of the two lines' line-content => 1.1757813 and 0.55859375

Without :width, lines align within the widest line. Trailing spaces hang past the line's end edge: the right in left-to-right text, the left in right-to-left text. See Typography for :justify.

Direction

Each paragraph has a base direction. With :direction :auto, the first strong character decides: a paragraph starting with Hebrew or Arabic is right to left, so :start aligns it to the right and its runs read from the right:

(tr:layout-text "שלום world" font :width 120)
;; one line: direction :RTL, its line-content from x 35.45 to 120.0
;; runs, left to right: "world" (5..10, :LTR) then "שלום " (0..5, :RTL)

:direction :ltr or :rtl sets the base direction of every paragraph. It changes where neutral characters such as spaces and punctuation go, and the default alignment; strong characters keep their own direction.

Mixed text is reordered per line. In a left-to-right paragraph, a Hebrew word becomes a right-to-left run in the middle:

(map 'list (lambda (run)
             (list (tr:run-start run) (tr:run-end run)
                   (tr:run-direction run) (tr:run-script run)))
     (tr:line-runs
      (aref (tr:layout-lines (tr:layout-text "Hello שלום world" font)) 0)))
;; => ((0 6 :LTR "Latn") (6 10 :RTL "Hebr") (10 11 :LTR "Hebr") (11 16 :LTR "Latn"))

The space after the Hebrew word is a run of its own: it is in the Hebrew script run but resolves to left to right.

Line height and spacing

Each line is as tall as the fonts on it need: its height is

line-spacing × (largest ascent + largest descent + largest line gap)

over the fonts its glyphs come from (or the font at its start, for an empty line). The space beyond ascent and descent, from the line gap and from :line-spacing, is split equally above and below the text. The baseline is at the line's top plus half that space plus the ascent:

(tr:layout-text (format nil "a~%b") font :line-spacing 1.5)
;; line 0: top 0.0, baseline 19.507813, bottom 27.9375
;; line 1: top 27.9375, baseline 47.445313, bottom 55.875

line-ascent and line-descent give the values a line used. Lines with a larger font in a span grow to fit it; see Styled Text and Fallback.

When to lay out again

Lay the text out again when the text, the fonts, the width or any option changes; keep the layout and draw from it as long as they do not. Layout allocates, so a renderer that runs every frame should not lay out every frame. See Threads and Lifetimes.

Text that does change every frame, as a timer, a score or a number being dragged, is laid out again into the layout that showed it before, with :into, which reuses what the layout holds:

(setf layout (tr:layout-text (format nil "~,2F" seconds) font :into layout))

A simple line laid out again so allocates nothing, once the layout has held one as long: text without spans, horizontal and left to right, in one script beside common characters such as digits and punctuation, without tabs or paragraph separators, each character a grapheme of its own with a glyph in the first font, that fits the width, with a width and line spacing of single-floats, if given. It lands where a new layout would put it. Other text is laid out as anew, and the layout takes the result. A layout laid out again changes in place, so do it where no other thread reads it, as between the frames that draw it.