Text on Curves

Text is placed along a parametric curve in two separate steps:

  1. TextLayout — pure geometry. Works out where every character goes and returns a Vector{PlacedGlyph}. No plotting, no file I/O.
  2. 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 .pdf

PlacedGlyph

One laid-out character:

FieldTypeMeaning
charCharThe character
positionPoint2{Float64}Where it sits, in plot coordinates
angleFloat64Rotation in radians (the curve's local tangent)
fontsizeFloat64Size 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.

FunctionPurpose
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.findfont performs fuzzy fallback — asking for "CMU Serif" silently returns PT Serif. That is why load_font verifies the resolved family name and raises if it does not match, and why computer_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

FieldDefaultDescription
fontsize24.0Font size when auto_fit=false; starting guess when true
auto_fittrueScale the font so text fills target_fill_ratio of the arc
target_fill_ratio0.95Fraction of arc length to fill (0–1)
baseline_offset0.0Offset from the curve along its normal, in em units
adaptivetrueVary size with local curvature (see below)
curvature_threshold1.0Curvature at which size is exactly fontsize
min_scale0.3Lower bound, as a fraction of the base size
max_scale2.0Upper 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 → exactly fontsize
  • κ > 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.

CurveFormulaNotes
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 adds turns

heart spans roughly ±16 in x and ±18 in y, so wrap it before use:

small_heart(t) = heart(t) * 0.1

Complete 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_SCALE in TextLayout.jl is the single constant converting em × fontsize into 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.