Text on Curves
Text is placed along a parametric curve in two separate steps:
TextLayout— pure geometry. Works out where every character goes and returns aVector{PlacedGlyph}. No plotting, no file I/O.TextRender— takes that vector and draws it with CairoMakie.
The Vector{PlacedGlyph} between them is the seam: it is plain data you can inspect, test, transform, or feed to a renderer other than Makie.
using DigitalArt
fm = computer_modern() # font metrics
glyphs = layout_text("Creating Symmetry", circle, fm) # geometry
fig = render_glyphs(glyphs; f=circle, show_curve=true)
save_render(fig, "curved_text") # writes .png and .pdfPlacedGlyph
One laid-out character:
| Field | Type | Meaning |
|---|---|---|
char | Char | The character |
position | Point2{Float64} | Where it sits, in plot coordinates |
angle | Float64 | Rotation in radians (the curve's local tangent) |
fontsize | Float64 | Size it was laid out at |
Because layout is pure, you can check results without rendering anything:
glyphs = layout_text("Hello", circle, fm)
all(g -> isfinite(g.angle), glyphs) # true
[g.char for g in glyphs] # ['H','e','l','l','o']Fonts and metrics
Character widths come from real FreeType metrics, never a hand-written width table. All layout math is done in em units and multiplied by the font size exactly once, so the layout is font-size independent.
| Function | Purpose |
|---|---|
computer_modern(; style=:regular) | The project default face — Knuth's TeX serif. style ∈ :regular, :bold, :italic, :bolditalic |
computer_modern_path(; style=:regular) | File path to the same face, for passing to Makie |
load_font_file(path) | Load any face by file path |
load_font(name) | Load by name — errors rather than silently substituting |
advance(fm, c) | Advance width of c, in em units |
kern(fm, a, b) | Kerning between a and b, in em units |
Computer Modern is loaded by file path from the OTFs bundled inside the MathTeXEngine package, so no system font installation is required.
FreeTypeAbstraction.findfontperforms fuzzy fallback — asking for"CMU Serif"silently returns PT Serif. That is whyload_fontverifies the resolved family name and raises if it does not match, and whycomputer_modern()bypasses name lookup entirely.
layout_text
layout_text(text, curve, fm; t_start=0.0, t_end=2π, opts=LayoutOptions())curve is any function t -> SVector{2}. Returns Vector{PlacedGlyph}.
LayoutOptions
| Field | Default | Description |
|---|---|---|
fontsize | 24.0 | Font size when auto_fit=false; starting guess when true |
auto_fit | true | Scale the font so text fills target_fill_ratio of the arc |
target_fill_ratio | 0.95 | Fraction of arc length to fill (0–1) |
baseline_offset | 0.0 | Offset from the curve along its normal, in em units |
adaptive | true | Vary size with local curvature (see below) |
curvature_threshold | 1.0 | Curvature at which size is exactly fontsize |
min_scale | 0.3 | Lower bound, as a fraction of the base size |
max_scale | 2.0 | Upper bound, as a fraction of the base size |
Auto-fit brackets the answer first and then binary-searches, so it can grow the text as well as shrink it — fontsize is a starting guess, not a ceiling.
Orientation: text follows the curve
Every glyph is rotated to the curve's own local tangent at its position. No correction is applied to keep letters upright.
This means text wrapped around a closed curve is upside-down on the far arc. That is intentional. Any "readability" flip — whether applied per glyph or per string — introduces a seam where the orientation jumps by π, and at that seam the reading direction reverses. The discontinuity looks worse than the inversion it removes.
If you want text that stays upright throughout, lay it out along an open arc (for example t_start=0, t_end=π) rather than a full loop, where the tangent never rotates far enough to invert.
baseline_offset
Shifts text off the curve along the local normal, in em units, so glyphs sit beside the curve rather than centred on it. Positive values offset along the tangent rotated 90° counter-clockwise; negative values go the other way.
layout_text(txt, circle, fm; opts=LayoutOptions(baseline_offset=0.3))At a cusp — a point where the curve's derivative vanishes, such as the heart's notch at t=0 — the normal is undefined. compute_curve_normal_direction probes just either side for a usable tangent rather than returning NaN, so glyphs at cusps are still placed.
Adaptive sizing
With adaptive=true, character size varies with local curvature: larger on straight sections, smaller on tight bends.
- κ = 0 →
fontsize × max_scale - κ =
curvature_threshold→ exactlyfontsize - κ > threshold → exponential decay, floored at
fontsize × min_scale
A caution. On a curve with sharp corners, adaptive sizing compounds with auto-fit and can produce a very wide spread — on the heart, roughly 6× between the straight flanks and the point, which reads as a defect rather than a flourish. Set adaptive=false for an even typographic texture; both example renders in scripts/generate_artwork.jl do exactly that.
Rendering
render_glyphs(glyphs; f=nothing, t_range=(0.0, 2π),
font=computer_modern_path(), color=:black,
show_curve=false, curve_color=:lightblue,
figure_size=(800, 800)) # -> Figure
save_render(fig, "basename") # writes basename.png + .pdf
render_animation(glyphs, "out.mp4"; f=curve, framerate=10)render_glyphs pads the axis limits beyond the glyph anchor points, since a glyph is drawn around its anchor and text on the outermost part of a curve would otherwise be cropped.
render_animation reveals glyphs one at a time onto a fixed axis — the project's "creation flow" goal, applied to letters instead of curve traces.
Built-in curves
All are functions t -> SVector{2}, parameterised over [0, 2π] except spiral.
| Curve | Formula | Notes |
|---|---|---|
circle(t) | (cos t, sin t) | Radius 1 |
figure_eight(t) | (sin t, sin t cos t) | Lemniscate, width ~2 |
heart(t) | (16 sin³t, 13 cos t − 5 cos 2t − 2 cos 3t − cos 4t) | Scale by ~0.1; has a cusp at t=0 |
oval(t; a=2, b=1) | (a cos t, b sin t) | Ellipse |
spiral(t) | (t cos t, t sin t) | Scale by ~0.1; range beyond 2π adds turns |
heart spans roughly ±16 in x and ±18 in y, so wrap it before use:
small_heart(t) = heart(t) * 0.1Complete example
using DigitalArt
fm = computer_modern()
# Title around a circle
glyphs = layout_text(
"Creating Symmetry: The Artful Mathematics of Wallpaper Patterns",
circle, fm;
opts=LayoutOptions(auto_fit=true, target_fill_ratio=0.95,
baseline_offset=0.3, adaptive=false))
save_render(render_glyphs(glyphs; f=circle, show_curve=false), "text_circle")
# A sentence around a heart
small_heart(t) = heart(t) * 0.1
glyphs = layout_text(
"Thou shalt neither vex a stranger, nor oppress him.",
small_heart, fm;
opts=LayoutOptions(auto_fit=true, target_fill_ratio=0.90,
baseline_offset=0.3, adaptive=false))
save_render(render_glyphs(glyphs; f=small_heart, show_curve=false), "text_heart")Notes
EM_SCALEinTextLayout.jlis the single constant convertingem × fontsizeinto plot units. If text is globally too large or small relative to the curve, adjust it there — don't scatter factors elsewhere.- Spaces are placed like any other character. They have a real advance width; there is no whitespace special-casing.
- Characters that would run past the end of the curve are dropped.