vellum ships the shaper and the font tables in-process, and it measures text when a scene is built rather than when it is drawn. Between them, those two facts let it do things with text that R’s graphics stack otherwise cannot: fit text to a box, set it along a curve, stroke the glyph outlines, and ask the font for alternate glyphs.
Text that fits a box
Give text_grob() a width and it wraps:
caption <- paste(
"Text that has to fit a fixed measure is the ordinary case for a subtitle, a",
"caption, or an annotation pinned to a panel. Breaking it by hand is guesswork,",
"because the right break depends on the font, the size, and the OpenType",
"features actually in force."
)
display(
vl_scene(6, 1.8, dpi = 96, bg = "white") |>
draw(rect_grob(width = vl_unit(80, "mm"), height = vl_unit(38, "mm"),
gp = vl_gpar(fill = "grey97", col = "grey85"))) |>
draw(text_grob(caption, width = vl_unit(74, "mm"), gp = vl_gpar(fontsize = 9)))
)
The break decision is made on the shaped width of each candidate line, not on a sum of per-word advances. Kerning and any active feature are therefore accounted for, and a line can never render wider than it measured — which is a guarantee, not a tendency, and is what makes auto-fit below trustworthy.
width has to be an absolute unit
(mm/cm/in/pt), and
that restriction is the interesting part. Wrapping happens when the grob
is constructed, and a viewport’s size in npc or
native does not exist until render time. Rather than
pretend otherwise, vellum asks for the physical measure you want to wrap
to. A layout engine above vellum knows its panel widths in millimetres,
so this costs it nothing.
Alignment
align positions lines within the box. Note that the
block is a box of exactly the requested width, so
just anchors the box rather than the longest line — which
is what lets a right-aligned caption line up with a panel edge.
box <- function(scene, x, align) {
scene |>
draw(rect_grob(x = x, y = 0.46, width = vl_unit(40, "mm"),
height = vl_unit(34, "mm"),
gp = vl_gpar(fill = "grey97", col = "grey85"))) |>
draw(text_grob(sprintf('"%s"', align), x = x, y = 0.92,
gp = vl_gpar(fontsize = 9, col = "grey40"))) |>
draw(text_grob(caption, x = x, y = 0.46, width = vl_unit(36, "mm"),
align = align, gp = vl_gpar(fontsize = 6)))
}
display(
vl_scene(6, 2.6, dpi = 96, bg = "white") |>
box(0.14, "left") |> box(0.38, "centre") |>
box(0.62, "right") |> box(0.86, "justify")
)
"justify" stretches the inter-word spaces to flush both
edges, leaving the last line of each paragraph ragged — a two-word final
line stretched to full measure is the classic ugly artifact.
Auto-fit
fit = TRUE shrinks the font — never grows it — until the
wrapped block fits width × height:
fitted <- vl_scene(6, 2.2, dpi = 96, bg = "white")
hs <- c(30, 20, 12)
for (i in seq_along(hs)) {
x <- c(0.18, 0.5, 0.82)[i]
fitted <- fitted |>
draw(rect_grob(x = x, y = 0.45, width = vl_unit(38, "mm"),
height = vl_unit(hs[i], "mm"),
gp = vl_gpar(fill = NA, col = "grey80", lty = "dashed"))) |>
draw(text_grob(caption, x = x, y = 0.45, width = vl_unit(36, "mm"),
height = vl_unit(hs[i] - 2, "mm"), fit = TRUE,
gp = vl_gpar(fontsize = 11))) |>
draw(text_grob(paste0(hs[i], "mm tall"), x = x, y = 0.93,
gp = vl_gpar(fontsize = 8, col = "grey40")))
}
display(fitted)
Each probe re-wraps, because the line breaks depend on the size — this is a search, not a scale factor. One size is chosen for the whole grob: a row of labels at four different sizes is a defect, not a feature.
This is the piece that genuinely cannot be built on grid. Fitting text to a box requires measuring it before it is drawn, and in grid a string has no width until a device is open.
Text on a path
text_path_grob() takes a polyline baseline instead of a
point. Each glyph keeps the pen position shaping gave it and is placed
that far along the path, rotated to the local tangent.
arc <- function(from, to, r = 0.34) {
th <- seq(from, to, length.out = 120)
list(x = 0.5 + r * cos(th), y = 0.5 + r * sin(th))
}
upper <- arc(pi, 0)
lower <- arc(pi, 2 * pi) # reversed, so it reads the right way up
display(
vl_scene(3.2, 3.2, dpi = 96, bg = "white") |>
draw(circle_grob(r = 0.4, gp = vl_gpar(fill = NA, col = "grey75", lwd = 1.5))) |>
draw(text_path_grob("MEASURED AT CONSTRUCTION", x = upper$x, y = upper$y,
offset = 5, gp = vl_gpar(fontsize = 10))) |>
draw(text_path_grob("NOT AT DRAW TIME", x = lower$x, y = lower$y,
offset = -12, gp = vl_gpar(fontsize = 10))) |>
draw(text_grob("vellum", gp = vl_gpar(fontsize = 14, col = "grey35")))
)
Two things to know.
Glyphs follow the tangent unconditionally, exactly as SVG
textPath does, so a label on the underside of a closed
curve reads upside-down. The fix is to reverse the
path, as lower does above — not the
glyphs. Flipping glyphs individually would put them the right way up but
in reverse order, which is mirror-writing, so vellum does not offer
it.
Arc length is measured on the rendered path, which is why
this lives in the engine rather than in R: glyph advances are in points
and the baseline is in npc or native, and
nothing can put those in the same space until the viewport’s pixel
extent is known.
On-path text is an ordinary text node with a baseline attached, so
everything else still applies — halos, features, colour, clipping, and
all three backends. The run is fanned out into one glyph per position at
draw time, so SVG output is still real <text> and PDF
still carries copyable text:
x <- seq(0.04, 0.96, length.out = 200)
display(
vl_scene(6, 1.8, dpi = 96, bg = "#20304A") |>
draw(text_path_grob(
"a halo keeps a label legible wherever the curve takes it",
x = x, y = 0.5 + 0.22 * sin(x * 3 * pi), just = "left", offset = 3,
gp = vl_gpar(fontsize = 12, col = "white",
halo_col = "#20304A", halo_width = 2.5)
))
)
Halos
A label over a dense scatter, a photograph, or a map tile is hard to read whatever colour you make it. The usual fix is a halo (or “shadowtext”): the glyph outlines stroked in a contrasting colour underneath the fill.
Packages that do this on top of grid draw the label eight times at small offsets, because grid gives them no way to stroke a glyph. vellum has the outlines, so it strokes them once:
set.seed(1)
n <- 500
cloud <- vl_scene(6, 2, dpi = 96, bg = "grey20") |>
draw(points_grob(runif(n), runif(n), size = vl_unit(1.8, "mm"),
gp = vl_gpar(fill = "#7FB2E5AA", col = NA)))
display(
cloud |>
draw(text_grob("no halo", x = 0.28, y = 0.5,
gp = vl_gpar(fontsize = 26, col = "white"))) |>
draw(text_grob("with halo", x = 0.72, y = 0.5,
gp = vl_gpar(fontsize = 26, col = "white",
halo_col = "black", halo_width = 3)))
)
halo_width is in points, like
fontsize, so a halo keeps its proportion at any dpi or
figure size. Roughly an eighth of the font size is a good starting
point. A halo needs both halo_col and a positive
halo_width; either on its own does nothing.
The halo is drawn as a complete pass before any glyph is filled, so a
wide halo on one letter never paints over the neighbour that was already
drawn. All three backends agree: the raster and outline-SVG paths do the
two passes explicitly, native SVG uses paint-order, and PDF
strokes then fills.
Because a sprite bakes only the fill, haloed text always takes the exact outline path — the glyph-bitmap fast path is bypassed for it. Halos are usually a handful of labels, so this costs nothing in practice.
OpenType features
A font often contains more glyphs than its default mapping exposes:
tabular figures, small caps, oldstyle numerals, alternate ligatures.
features takes a named vector of four-character OpenType
tags and passes them to the shaper.
kerned <- text_grob("AV Wa To Ty", x = 0.5, y = 0.72,
gp = vl_gpar(fontfamily = "Times", fontsize = 30))
loose <- text_grob("AV Wa To Ty", x = 0.5, y = 0.28,
gp = vl_gpar(fontfamily = "Times", fontsize = 30,
features = c(kern = 0)))
display(vl_scene(6, 2, dpi = 96) |> draw(kerned) |> draw(loose))
The tags worth knowing:
| tag | effect |
|---|---|
tnum |
tabular (fixed-width) figures — digits stop jittering between ticks |
onum |
oldstyle figures, which sit better in running text |
smcp |
small caps |
liga |
standard ligatures (0 switches them off) |
kern |
kerning (0 switches it off) |
Tabular figures are the one to reach for first.
Proportional digits make an axis label shift horizontally as its value
changes, which reads as jitter when a plot animates or a table updates.
c(tnum = 1) fixes that in one argument.
Two caveats. A feature the font does not carry is silently ignored —
that is HarfBuzz’s behaviour, and vellum cannot check it for you, so
compare output rather than assuming. And most system UI fonts already
ship tabular digits, so tnum is frequently a no-op; the
features that pay off tend to live in text faces and commercial
fonts.
Measurement follows features
Features change glyph widths, so a size measured without them would
reserve the wrong space. vl_strwidth(),
vl_strheight(), grobwidth() and
grobheight() all honour the same features, and
the shape cache is keyed on them:
c(kerned = vl_strwidth("AV Wa To Ty", "Times", fontsize = 30, unit = "mm"),
unkerned = vl_strwidth("AV Wa To Ty", "Times", fontsize = 30, unit = "mm",
features = c(kern = 0)))
#> kerned unkerned
#> 57.22166 61.41641Both together
Neither costs anything when unused: a scene with no halo and no features renders byte-for-byte as it did before either existed.
display(
vl_scene(6, 1.6, dpi = 96, bg = "#22303C") |>
draw(text_grob("1,234.56", x = 0.5, y = 0.5,
gp = vl_gpar(fontfamily = "Times", fontsize = 34,
col = "#F5D76E", features = c(tnum = 1),
halo_col = "#101820", halo_width = 2.5)))
)
