typography.kt
src/jvmMain/kotlin/afield/graphics/text/typography.kt · 1480 lines
package and imports — 113 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Graphics")
package afield.graphics.text
import afield.data.collections.invoke
import afield.data.collections.times
import afield.data.graph.main
import afield.document.context.Why
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.geometry.mesh.contains
import afield.geometry.mesh.march
import afield.geometry.mesh.repeated
import afield.geometry.mesh.rotated
import afield.geometry.mesh.toFLines
import afield.geometry.spatial.toFLines
import afield.graphics.render.circle
import afield.graphics.render.label
import afield.graphics.render.rangeTo
import afield.graphics.render.rect
import afield.graphics.shader.mesh
import afield.graphics.shader.vertex
import afield.io.osc.main
import afield.lang.stacksl.evolution.render
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.round
import afield.math.autodiff.sign
import afield.math.autodiff.times
import afield.math.control.main
import afield.math.linalg.ab
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.statistics.main
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.isoContourFLines
import afield.media.image.montage
import afield.media.image.open
import afield.media.image.toFLines
import afield.runtime.execution.animate
import afield.runtime.execution.end
import afield.runtime.execution.go
import afield.runtime.lifecycle.cached
import afield.runtime.lifecycle.once
import afield.simulation.agents.toFLines
import afield.simulation.automata.main
import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.get
import afield.data.collections.m
import afield.data.collections.r
import afield.data.collections.s
import afield.document.graph.named
import afield.document.properties.clear
import afield.document.workspace.absolutePath
import afield.document.workspace.file
import afield.document.workspace.path
import afield.geometry.mesh.bounds
import afield.graphics.render.color
import afield.graphics.render.cubicTo
import afield.graphics.render.fline
import afield.graphics.render.lineTo
import afield.graphics.render.moveTo
import afield.graphics.render.seg
import afield.graphics.vulkan.buffers.add
import afield.lang.stacksl.h
import afield.math.autodiff.sum
import afield.math.autodiff.tan
import afield.math.functions.tan
import afield.math.linalg.Vec2
import afield.math.linalg.Vec4
import afield.math.linalg.d
import afield.math.linalg.f
import afield.math.linalg.i
import afield.math.linalg.l
import afield.math.linalg.wx
import afield.math.linalg.wy
import afield.math.linalg.x
import afield.media.image.rotate
import afield.media.image.split
import afield.runtime.execution.start
import afield.ui.output.h
import afield.app.protocol.Rect
import afield.geometry.path.FLine
import java.awt.Font
import java.awt.Shape
import java.awt.font.FontRenderContext
import java.awt.font.TextAttribute
import java.awt.geom.AffineTransform
import java.awt.geom.PathIterator
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
Text as geometry: a string becomes contours, and from there everything Field already does to geometry applies to letters.
A layout here is a value you can extrude, march, sample or animate, not a label painted into a
raster — which is what every other use of java.awt.Font in the tree does (MeshGallery,
ImagingGallery, montage's 5x7 bitmap font). It needs no dependency beyond AWT:
Font.createGlyphVector(...).getOutline() hands back real quadratic/cubic glyph outlines, and it
works headless. typography_field.kt is the bridge to Field's own 2D and 3D outputs.
The three values
- Typeface — a font at a size, from a logical/system name (typeface) or a
.ttf/.otfon disk (typefaceFromFile). Carries the vertical metrics (ascent, descent, leading) a layout needs. - Glyph — one glyph's outline, cached per typeface and glyph code, in its own frame with its
origin on its own baseline. Both representations live side by side:
Glyph.contoursare flattened, nesting-classified, winding-normalised polygons for geometry work, andGlyph.outlineis the same rings as an FLine of real cubics for drawing. - TextLayout — glyphs placed on baselines by AWT's own advances and kerning, plus tracking,
multi-line and alignment. Every glyph in it is a PlacedGlyph carrying mutable transform
handles (offset, rotation, scale, pivot). Those handles are how you animate letters
individually, and
TextLayout.onCurveis nothing but a routine that sets them from an FLine's arc length.
Text space
Glyph and layout coordinates are x right, y down, origin at the first baseline, units = the
typeface's size in points. That is AWT's own glyph frame and it is also the frame Field's 2D
outputs use (Rect's y grows downwards), so text handed straight to Box.lines or
Box.showText reads the right way up with no transform. The 3D bridges in typography_field.kt
flip y at their own boundary, because a mesh wants y up.
The winding convention
A glyph's counter — the hole in o, e, a, B — is only a hole if something says so, and what
the font shipped is not enough to go on. (isoContourFLines in contours.kt leaves winding
arbitrary, and every consumer downstream has to cope.) So this file decides:
For a ring
p0..pn, signed area is½Σ(xᵢyᵢ₊₁ − xᵢ₊₁yᵢ)in text space. Outer contours are positive; holes are negative. A ring'sGlyphContour.depth— how many other rings of the same glyph contain it — is authoritative: even depth is an outer contour, odd depth is a hole. A ring whose area sign disagrees with its depth is reversed on the way out.
Both representations obey it, so contours.filter { it.isHole } and the subpaths of
Glyph.outline agree, and TypographyTest asserts it on glyphs whose answer is known by eye
(o has one hole, l has none, % has two).
AWT and the main run loop
imaging_field.kt captions montage with a bitmap table instead of Graphics2D.drawString, for
a reason that reaches this file: on macOS, touching the AWT font manager pulls in AppKit, which
competes with GLFW for the main run loop, and Server.main never initialises the toolkit. This
family cannot avoid the font manager — it is the font manager — so it contains the hazard in one
place, Typefaces.warm, whose KDoc carries the measured stack trace and the conclusion:
A JVM that wants glyph outlines and passes
-XstartOnFirstThreadmust also pass-Djava.awt.headless=true. OtherwiseCFontManager.loadFontsdispatches to the process's first thread and waits forever for a run loop nobody is pumping.
Typefaces.warm detects that in four seconds and throws with that sentence in it, rather than
hanging the editor. Every jvm-main gallery in the tree already sets the property in the first line
of main, jvmTest does not need it (Gradle's test JVM has no -XstartOnFirstThread), and an
interactive Field window pumps its run loop through glfwPollEvents and so is expected to be
fine — expected, not tested; see Typefaces.warm. Everything after that one touch is pure
computation over cached outlines.
Typeface
internal val typographyFrc: FontRenderContext = FontRenderContext(AffineTransform(), true, true)
The render context every measurement in this file goes through.
Identity transform, antialiasing on, fractional metrics on — the last one matters: with integer metrics AWT rounds every advance to a whole pixel, which at 12pt is a 4% layout error and makes tracking and kerning comparisons meaningless.
class Typeface internal constructor
A font at a size: the thing you ask for glyphs and layouts.
Immutable, and cheap to derive from (atSize, withKerning) because the underlying java.awt.Font
is shared and the glyph cache is keyed by the AWT font, so two Typefaces over the same font at
the same size share their extracted outlines.
class Typeface internal constructor(
/**
* The AWT font, exposed: anything AWT can do to a font (attributes, transforms, `deriveFont`)
* is available, and [Typeface.of] wraps the result back up.
*/
val awtFont: Font,
) {
val size: Double = awtFont.size2D.toDouble()
Point size, as a Double — awtFont.size truncates to Int.
val familyName: String get() = awtFont.family
e.g. Helvetica.
val fontName: String get() = awtFont.fontName
e.g. Helvetica-Bold, which is what a .ttf reports and what round-trips through AWT.
val ascent: Double
Distance from the baseline up to the top of most glyphs, positive, in text-space units.
val descent: Double
Distance from the baseline down to the bottom of descenders, positive.
val leading: Double
The font's recommended extra space between lines, positive.
val lineHeight: Double get() = ascent + descent + leading
ascent + descent + leading — the natural baseline-to-baseline distance at this size.
val kerning: Boolean = awtFont.attributes[TextAttribute.KERNING] == TextAttribute.KERNING_ON
Whether AWT will apply this font's kerning pairs during layout.
init
init {
Typefaces.warm()
val m = awtFont.getLineMetrics("Hxpg", typographyFrc)
ascent = m.ascent.toDouble()
descent = m.descent.toDouble()
leading = m.leading.toDouble()
}
fun atSize(size: Number): Typeface = of(awtFont.deriveFont(size.toFloat()))
The same font at a different size.
fun withKerning(on: Boolean): Typeface
The same font with AWT's kerning-pair lookup switched on or off.
Not every font has a kern table, and logical fonts (SansSerif, …) resolve to whatever the
platform picked, so this can legitimately be a no-op — TextLayout.advanceWidth is the authoritative
way to find out whether it did anything for a given font and string.
fun withKerning(on: Boolean): Typeface {
// KERNING has exactly one legal value; "off" is the attribute's *absence*, not a zero, so
// the map value has to be null rather than 0 or AWT keeps kerning on.
val attrs = mapOf<TextAttribute, Any?>(TextAttribute.KERNING to if (on) TextAttribute.KERNING_ON else null)
return of(awtFont.deriveFont(attrs))
}
fun withStyle(bold: Boolean = false, italic: Boolean = false): Typeface
The same font emboldened / italicised through AWT's style bits (synthetic if the family lacks it).
fun withStyle(bold: Boolean = false, italic: Boolean = false): Typeface = of(
awtFont.deriveFont((if (bold) Font.BOLD else 0) or (if (italic) Font.ITALIC else 0))
)
fun canDisplay(text: String): Boolean = awtFont.canDisplayUpTo(text) < 0
True when this font has a glyph for every character of text.
fun glyph(c: Char): Glyph
One glyph's outline, by character. Convenience over layoutText for a single letter.
fun glyph(c: Char): Glyph = layoutText(c.toString(), this).glyphs.firstOrNull()?.glyph
?: Glyph(0, c.toString(), 0.0, emptyList(), FLine(), Rect(0.0, 0.0, 0.0, 0.0))
override fun toString(): String = "Typeface($fontName @ ${"%.1f".format(size)}pt)"
override fun equals(other: Any?): Boolean = other is Typeface && other.awtFont == awtFont
override fun hashCode(): Int = awtFont.hashCode()
companion object
companion object {
fun of(font: Font): Typeface = Typeface(font)
Wrap an AWT font you already have (e.g. from your own deriveFont).
object Typefaces
Font loading, the glyph cache, and the one dangerous moment in this family.
warm is the only place the AWT font manager is initialised, and it is worth reading its KDoc before changing anything here: which thread touches it first decides whether it answers at all on macOS. Call warm yourself at startup if you want the one-off cost out of the way.
object Typefaces {
How long a helper thread is given before warm concludes that AWT is waiting on the thread that called it, and does the work inline instead.
Long enough that a cold font-directory walk on a slow disk is not mistaken for a deadlock, short enough that the fallback happens inside a single box execution.
private const val WARM_TIMEOUT_MS = 4_000L
@Volatile
private var warmed = false
val isWarm: Boolean get() = warmed
True once warm has succeeded, so every later layout is pure computation. A caller on the main run loop can check this to avoid the stall warm describes, and warm from another thread instead.
@Volatile
private var environmentWarmed = false
private val glyphCache = ConcurrentHashMap<GlyphKey, Glyph>()
private data class GlyphKey(val font: Font, val glyphCode: Int)
fun warm()
Initialise the AWT font manager once, on a helper thread, and refuse to hang if it will not answer.
Measured 2026-07-30, and the whole reason this function exists. In a JVM started with
-XstartOnFirstThread that is not pumping its run loop, the first
Font.createGlyphVector(...) never returns. The sampled stack says exactly why:
sun.font.CFontManager.loadFonts <- sun.font.SunFontManager.findFont2D <- java.awt.Font.getFont2D
<- sun.font.StandardGlyphVector.<init> <- java.awt.Font.createGlyphVector
CFontManager.loadFonts dispatches to the process's first thread and waits. macOS GLFW
requires -XstartOnFirstThread, so Field passes it to every jvm-main child
(agent_backdoor.kt:856) — and in a child that opens no window, nothing ever services that
dispatch. Running it inline on the caller does not help either; the caller is not that thread.
The fix is java.awt.headless=true, and it has to be set before AWT initialises: headless
AWT does not route the font manager through the main thread at all. That is why every jvm-main
gallery in the tree starts with System.setProperty("java.awt.headless", "true") and why they
never saw this. This sets it too, under -XstartOnFirstThread when nobody chose a value
(headlessUnderFirstThread), which works when reading an outline is the JVM's first AWT use and
is too late after a box has already touched BufferedImage or Skiko.
So this throws rather than blocks, and the message names the fix. imaging_field.kt:417's rule
is that a stdlib call every box might make must not be able to take the process down; a hang
would, and a diagnosable exception does not. Idempotent and cheap after the first call — the
whole family is pure computation over cached outlines from here on.
Verified working: jvmTest (no -XstartOnFirstThread), the jvm-main galleries and
DemoTranscriptExecSmokeTest (headless set first). An interactive Field window pumps the run
loop through glfwPollEvents, so it is expected to be fine and is not covered by an automated
test — if a live box reports this error, the flag is the answer there too.
fun warm() {
if (warmed) return
synchronized(this) {
if (warmed) return
headlessUnderFirstThread()
// One real glyph outline: that is what loads libfontmanager and the platform's font
// engine. Deliberately NOT GraphicsEnvironment — the family list is a separate, heavier
// hazard, and only [available] needs it.
val probe = { Font(Font.SANS_SERIF, Font.PLAIN, 12).createGlyphVector(typographyFrc, "o").outline }
if (offThread("afield-typography-init", probe) == null) {
throw IllegalStateException(stallMessage("the AWT font manager"))
}
warmed = true
}
}
Under macOS -XstartOnFirstThread a non-headless AWT font manager waits on a run loop nobody
pumps, so set java.awt.headless=true here when the caller has not chosen either way. It only
takes effect if no AWT class has initialised yet, which is the usual case for a JVM whose first
AWT use is reading an outline; if it is too late, warm's timeout still names the flag.
private fun headlessUnderFirstThread() {
if (System.getProperty("java.awt.headless") != null) return
if (!System.getProperty("os.name").orEmpty().startsWith("Mac")) return
// The macOS launcher marks a -XstartOnFirstThread process with this variable.
if (System.getenv("JAVA_STARTED_ON_FIRST_THREAD_${ProcessHandle.current().pid()}") != "1") return
System.setProperty("java.awt.headless", "true")
}
private fun stallMessage(what: String) =
"afield.stdlib typography: $what did not answer within ${WARM_TIMEOUT_MS}ms, so this JVM cannot read " +
"glyph outlines.\n stuck at: ${lastWarmStall ?: "unknown"}\n" +
" This is the macOS -XstartOnFirstThread trap: CFontManager dispatches to the process's first " +
"thread and waits, and nothing here is pumping that run loop. Start the JVM with " +
"-Djava.awt.headless=true (it must be set BEFORE any AWT class initialises — see " +
"Typefaces.warm and the jvm-main galleries, which all set it in the first line of main)."
var lastWarmStall: String?
Where the last stalled warm attempt was, top frames first.
A stall with no stack is a dead end for whoever hits it next, so the helper thread is sampled before it is abandoned. Exposed because it is the evidence behind warm's KDoc, and a platform that stalls somewhere else should be able to say so.
@Volatile
var lastWarmStall: String? = null
private set
Run body on a fresh daemon thread under WARM_TIMEOUT_MS; null means it did not finish.
private fun <T> offThread(name: String, body: () -> T): T? {
var thread: Thread? = null
val exec = Executors.newSingleThreadExecutor { r ->
Thread(r, name).apply { isDaemon = true; thread = this }
}
try {
return exec.submit(body).get(WARM_TIMEOUT_MS, TimeUnit.MILLISECONDS)
} catch (e: java.util.concurrent.TimeoutException) {
lastWarmStall = thread?.stackTrace?.take(12)?.joinToString(" <- ") { "${it.className}.${it.methodName}" }
return null
} catch (e: java.util.concurrent.ExecutionException) {
throw IllegalStateException(
"afield.stdlib typography: the AWT font manager failed to initialise: ${e.cause?.message}",
e.cause
)
} finally {
// shutdownNow interrupts, but a thread blocked in a native AppKit wait will not notice; it
// is a daemon, so it cannot keep the JVM alive either way.
exec.shutdownNow()
}
}
fun named
A logical or installed font by name.
name may be a logical family (SansSerif, Serif, Monospaced, Dialog) or anything in
available. AWT silently substitutes for a name it does not know, so pass strict = true
when you would rather be told.
fun named(
name: String = Font.SANS_SERIF,
size: Number = 64.0,
bold: Boolean = false,
italic: Boolean = false,
kerning: Boolean = true,
strict: Boolean = false,
): Typeface {
warm()
if (strict) {
val known = name in LOGICAL_FAMILIES || available().any { it.equals(name, ignoreCase = true) }
require(known) { "no installed font family named '$name' (and it is not a logical family)" }
}
val style = (if (bold) Font.BOLD else 0) or (if (italic) Font.ITALIC else 0)
var f = Font(name, style, 12).deriveFont(size.toFloat())
if (kerning) f = f.deriveFont(mapOf(TextAttribute.KERNING to TextAttribute.KERNING_ON))
return Typeface(f)
}
fun fromFile(path: String, size: Number = 64.0, kerning: Boolean = true): Typeface
A .ttf / .otf / .ttc from disk, through Font.createFont(TRUETYPE_FONT, file).
The font is not registered with the graphics environment — it is used directly — so this
works headless and leaves no global state behind. createFont returns a 1pt font, hence the
immediate deriveFont.
fun fromFile(path: String, size: Number = 64.0, kerning: Boolean = true): Typeface {
warm()
val file = File(path)
require(file.isFile) { "no font file at ${file.absolutePath}" }
val kind = if (file.extension.lowercase() == "pfb") Font.TYPE1_FONT else Font.TRUETYPE_FONT
var f = Font.createFont(kind, file).deriveFont(size.toFloat())
if (kerning) f = f.deriveFont(mapOf(TextAttribute.KERNING to TextAttribute.KERNING_ON))
return Typeface(f)
}
fun available(): List<String>
Every font family the graphics environment can see, sorted.
Held apart from warm: this is the only thing in the family that needs a
GraphicsEnvironment, which is a second and heavier AWT hazard than the font manager
(warm's KDoc has the measurement). Loading a face by name, laying text out and extracting
outlines all avoid it entirely, so a live box never pays for it unless it asks for the list
— or passes strict = true to named, which is the same question in disguise.
fun available(): List<String> {
warm()
if (!environmentWarmed) {
synchronized(this) {
if (!environmentWarmed) {
val names = offThread("afield-typography-environment") {
java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames.size
} ?: throw IllegalStateException(
stallMessage("GraphicsEnvironment.getLocalGraphicsEnvironment()") +
"\n Note that enumerating installed families needs a graphics environment and " +
"loading a face BY NAME does not, so naming the face you want may be enough."
)
check(names >= 0)
environmentWarmed = true
}
}
}
return java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames.sorted()
}
fun firstAvailable(vararg candidates: String, size: Number = 64.0, fallback: String = Font.SANS_SERIF): Typeface
The first of candidates that is actually installed, else fallback.
For docs and galleries that want a particular look but must still run on a machine that has
never heard of it. Logical families always resolve, so fallback genuinely cannot fail.
fun firstAvailable(vararg candidates: String, size: Number = 64.0, fallback: String = Font.SANS_SERIF): Typeface {
val have = available().toSet()
val pick = candidates.firstOrNull { it in have } ?: fallback
return named(pick, size)
}
fun clearCache() = glyphCache.clear()
Drop every extracted outline. Only useful if you are churning through fonts from disk.
val cachedGlyphCount: Int get() = glyphCache.size
How many glyph outlines are currently cached — the number TypographyTest checks caching by.
internal fun cachedGlyph(font: Font, glyphCode: Int, build: () -> Glyph): Glyph =
The glyph cache. Keyed by (AWT font, glyph code) and not by character, because that is the
identity an outline actually has: ffi is one glyph, and A in two sizes is two.
Note the consequence for Glyph.chars: the first string to reach a glyph code names it, so a
ligature extracted from "waffle" reports the characters that produced it there. A caller who
needs its own characters reads PlacedGlyph.text.
internal fun cachedGlyph(font: Font, glyphCode: Int, build: () -> Glyph): Glyph =
glyphCache.computeIfAbsent(GlyphKey(font, glyphCode)) { build() }
private val LOGICAL_FAMILIES = setOf(
Font.SANS_SERIF, Font.SERIF, Font.MONOSPACED, Font.DIALOG, Font.DIALOG_INPUT
)
}
fun typeface
A logical or installed font by name — the short form of Typefaces.named.
fun typeface(
name: String = Font.SANS_SERIF,
size: Number = 64.0,
bold: Boolean = false,
italic: Boolean = false,
): Typeface = Typefaces.named(name, size, bold, italic)
fun typefaceFromFile(path: String, size: Number = 64.0): Typeface
A .ttf/.otf from disk — the short form of Typefaces.fromFile.
fun typefaceFromFile(path: String, size: Number = 64.0): Typeface = Typefaces.fromFile(path, size)
Glyph outlines
class GlyphContour internal constructor
One closed ring of a glyph outline, flattened to a polygon and classified.
The polygon does not repeat its first point at the end — the ring closes implicitly, which is
what every consumer here (FloatEarcut, point-in-polygon, the scanline filler) wants.
class GlyphContour internal constructor(
/**
* The ring as a polygon in text space, flattened to the tolerance the extraction asked for, and
* wound so that [signedArea] agrees with [depth]. The first point is not repeated at the end.
*/
val points: List<Vec2>,
/**
* How many other rings of the same glyph contain this one. 0 for the bowl of `o`, 1 for its
* counter. Even is an outer contour, odd is a hole.
*/
val depth: Int,
) {
val signedArea: Double
½Σ(xᵢyᵢ₊₁ − xᵢ₊₁yᵢ) in text space; positive for outer rings and negative for holes, per the
convention this file enforces. Its magnitude is the ring's area in square text-space units.
val signedArea: Double = run {
var sum = 0.0
for (i in points.indices) {
val a = points[i]
val b = points[(i + 1) % points.size]
sum += a.x.toDouble() * b.y.toDouble() - b.x.toDouble() * a.y.toDouble()
}
0.5 * sum
}
val isHole: Boolean get() = depth % 2 == 1
True for the counter of an o — an odd nesting depth, and so a negative signedArea.
val isOuter: Boolean get() = !isHole
True for the bowl of an o — an even nesting depth, and so a positive signedArea.
val bounds: Rect = boundsOfPoints(points)
Tight bounds in text space.
val perimeter: Double
Closed-ring perimeter, in text-space units.
val perimeter: Double = run {
var s = 0.0
for (i in points.indices) {
val a = points[i]
val b = points[(i + 1) % points.size]
s += hypot((b.x - a.x).toDouble(), (b.y - a.y).toDouble())
}
s
}
fun toPolyline(): FLine
This ring as its own closed FLine — a polyline, not the original cubics.
fun toPolyline(): FLine = fline {
points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) }
if (points.isNotEmpty()) lineTo(points[0].x, points[0].y)
}
fun contains(p: Vec2): Boolean = pointInRing(points, p.x.toDouble(), p.y.toDouble())
Winding-number test against this ring alone, ignoring nesting.
override fun toString(): String
override fun toString(): String =
"GlyphContour(${points.size} pts, depth=$depth, ${if (isHole) "hole" else "outer"}, area=${"%.1f".format(signedArea)})"
}
class Glyph internal constructor
One glyph's outline, in its own frame with the origin on its own baseline.
Cached per (AWT font, glyph code) by Typefaces, so a repeated letter costs one hash lookup. Both representations describe the same rings in the same order:
contours— flattened, depth-classified, winding-normalised. What the mesh, SDF, raster and point-sampling bridges consume.outline— onemoveTo-led subpath per contour, with the font's real cubics (quadratics promoted, becauseFLinehas no quadratic node). What you draw.
class Glyph internal constructor(
/**
* The font's own glyph id, not a character code: a ligature has one code and several
* characters, and the same code means different letters in different fonts.
*/
val glyphCode: Int,
/** The characters that produced this glyph the first time it was extracted. */
val chars: String,
/**
* How far the pen moves after this glyph, in text-space units. Whitespace has an advance and no
* contours.
*/
val advance: Double,
/**
* The rings as polygons, in the font's own order, depth-classified and winding-normalised. This
* is what the mesh, SDF, raster and point-sampling bridges consume.
*/
val contours: List<GlyphContour>,
/**
* The same rings in the same order, one `moveTo`-led subpath each, keeping the font's real
* cubics. This is what you draw, and its subpaths run in step with [contours].
*/
val outline: FLine,
/**
* Tight bounds of the outline (an empty rect at the origin for whitespace), which is the
* *visual* box: [advance] is generally wider, and italics can overhang it.
*/
val bounds: Rect,
) {
val isBlank: Boolean get() = contours.isEmpty()
True for a space, a tab, or any glyph the font draws nothing for.
val contourCount: Int get() = contours.size
Rings in total.
val holeCount: Int get() = contours.count { it.isHole }
Rings at odd nesting depth: 1 for o, 2 for %, 0 for l.
val outerCount: Int get() = contours.count { it.isOuter }
Rings at even nesting depth: 1 for o, 2 for i (stem and tittle).
val inkArea: Double get() = contours.sumOf { it.signedArea }
Ink area: outer rings minus holes. Always >= 0 for a well-formed glyph.
override fun toString(): String
override fun toString(): String =
"Glyph('${chars}' #$glyphCode, ${contours.size} contours, $holeCount holes, advance=${"%.2f".format(advance)})"
}
PathIterator -> rings. The heart of the file.
Why this is hand-rolled rather than delegating to FLinesAndJavaShapes.javaShapeToFLine.
That function (FLinesAndJavaShapes.kt:2188) flattens a whole Shape into a single FLine and
gives no handle on the individual rings — no per-contour split, so no nesting depth, and no way to
reverse one ring without touching the others. Winding control is the entire point here, so the
segments are parsed into per-ring lists first; from there a ring can be flattened, measured,
reversed and re-emitted independently, and the polygon and the cubic outline are guaranteed to
describe the same curve in the same direction.
private sealed interface RingSeg {
val to: Vec2
}
private class RingLine(override val to: Vec2) : RingSeg
private class RingCubic(val c1: Vec2, val c2: Vec2, override val to: Vec2) : RingSeg
private class RawRing(var start: Vec2, val segs: MutableList<RingSeg> = mutableListOf())
private fun v2(x: Double, y: Double) = Vec2(x.toFloat(), y.toFloat())
Split a glyph outline into rings, promoting quadratics to cubics and closing every ring.
source — 60 lines, from line 698
private fun ringsOf(shape: Shape): List<RawRing> {
val it = shape.getPathIterator(null)
val c = DoubleArray(6)
val out = mutableListOf<RawRing>()
var cur: RawRing? = null
var hx = 0.0
var hy = 0.0
while (!it.isDone) {
when (it.currentSegment(c)) {
PathIterator.SEG_MOVETO -> {
cur = RawRing(v2(c[0], c[1]))
out.add(cur)
hx = c[0]; hy = c[1]
}
PathIterator.SEG_LINETO -> {
cur?.segs?.add(RingLine(v2(c[0], c[1])))
hx = c[0]; hy = c[1]
}
PathIterator.SEG_QUADTO -> {
// A quadratic (P0, Q, P2) is the cubic (P0, P0 + 2/3(Q-P0), P2 + 2/3(Q-P2), P2).
// Exact, not an approximation.
cur?.segs?.add(
RingCubic(
v2(hx + 2.0 / 3.0 * (c[0] - hx), hy + 2.0 / 3.0 * (c[1] - hy)),
v2(c[2] + 2.0 / 3.0 * (c[0] - c[2]), c[3] + 2.0 / 3.0 * (c[1] - c[3])),
v2(c[2], c[3])
)
)
hx = c[2]; hy = c[3]
}
PathIterator.SEG_CUBICTO -> {
cur?.segs?.add(RingCubic(v2(c[0], c[1]), v2(c[2], c[3]), v2(c[4], c[5])))
hx = c[4]; hy = c[5]
}
PathIterator.SEG_CLOSE -> {
cur?.let { r ->
val last = r.segs.lastOrNull()?.to ?: r.start
if (abs(last.x - r.start.x) > 1e-5f || abs(last.y - r.start.y) > 1e-5f) {
r.segs.add(RingLine(Vec2(r.start)))
}
hx = r.start.x.toDouble(); hy = r.start.y.toDouble()
}
}
}
it.next()
}
// A glyph outline from AWT is always closed, but a hand-built Shape need not be, and an open
// ring would make the area formula and the parity fill disagree. Close anything left open.
out.forEach { r ->
val last = r.segs.lastOrNull()?.to
if (last != null && (abs(last.x - r.start.x) > 1e-5f || abs(last.y - r.start.y) > 1e-5f)) {
r.segs.add(RingLine(Vec2(r.start)))
}
}
return out.filter { it.segs.size >= 2 }
}
Reverse a ring in place-ish: same curve, opposite direction, so its signed area changes sign.
private fun reverseRing(r: RawRing): RawRing {
val pts = ArrayList<Vec2>(r.segs.size + 1)
pts.add(r.start)
r.segs.forEach { pts.add(it.to) }
val outSegs = mutableListOf<RingSeg>()
for (i in r.segs.indices.reversed()) {
val s = r.segs[i]
val from = pts[i]
outSegs.add(
when (s) {
is RingLine -> RingLine(Vec2(from))
is RingCubic -> RingCubic(Vec2(s.c2), Vec2(s.c1), Vec2(from))
}
)
}
return RawRing(Vec2(pts[pts.lastIndex]), outSegs)
}
Flatten a ring to a polygon, subdividing each cubic by its control-polygon length.
flatness is in text-space units, so the same value gives more samples on a 96pt glyph than a
12pt one, which is what you want. The returned list does not repeat the start point.
source — 55 lines, from line 784
private fun flattenRing(r: RawRing, flatness: Double): List<Vec2> {
val out = ArrayList<Vec2>(r.segs.size * 4)
var from = r.start
out.add(Vec2(from))
for (s in r.segs) {
when (s) {
is RingLine -> out.add(Vec2(s.to))
is RingCubic -> {
val chord = dist(from, s.c1) + dist(s.c1, s.c2) + dist(s.c2, s.to)
val n = ceil(chord / max(1e-6, flatness)).toInt().coerceIn(2, 64)
for (k in 1..n) {
val t = k.toDouble() / n
out.add(cubicAt(from, s.c1, s.c2, s.to, t))
}
}
}
from = s.to
}
// Drop consecutive duplicates and the repeated closing point.
val cleaned = ArrayList<Vec2>(out.size)
for (p in out) {
val last = cleaned.lastOrNull()
if (last == null || abs(last.x - p.x) > 1e-5f || abs(last.y - p.y) > 1e-5f) cleaned.add(p)
}
while (cleaned.size >= 2) {
val a = cleaned[0]
val b = cleaned[cleaned.lastIndex]
if (abs(a.x - b.x) <= 1e-5f && abs(a.y - b.y) <= 1e-5f) cleaned.removeAt(cleaned.lastIndex) else break
}
return cleaned
}
private fun dist(a: Vec2, b: Vec2) = hypot((b.x - a.x).toDouble(), (b.y - a.y).toDouble())
private fun cubicAt(p0: Vec2, c1: Vec2, c2: Vec2, p1: Vec2, t: Double): Vec2 {
val u = 1.0 - t
val a = u * u * u
val b = 3.0 * u * u * t
val c = 3.0 * u * t * t
val d = t * t * t
return v2(
a * p0.x + b * c1.x + c * c2.x + d * p1.x,
a * p0.y + b * c1.y + c * c2.y + d * p1.y
)
}
private fun ringArea(points: List<Vec2>): Double {
var sum = 0.0
for (i in points.indices) {
val a = points[i]
val b = points[(i + 1) % points.size]
sum += a.x.toDouble() * b.y.toDouble() - b.x.toDouble() * a.y.toDouble()
}
return 0.5 * sum
}
internal fun pointInRing(points: List<Vec2>, px: Double, py: Double): Boolean
internal fun pointInRing(points: List<Vec2>, px: Double, py: Double): Boolean {
var inside = false
var j = points.size - 1
for (i in points.indices) {
val yi = points[i].y.toDouble()
val yj = points[j].y.toDouble()
if ((yi > py) != (yj > py)) {
val xi = points[i].x.toDouble()
val xj = points[j].x.toDouble()
if (px < (xj - xi) * (py - yi) / (yj - yi) + xi) inside = !inside
}
j = i
}
return inside
}
internal fun boundsOfPoints(points: List<Vec2>): Rect
internal fun boundsOfPoints(points: List<Vec2>): Rect {
if (points.isEmpty()) return Rect(0.0, 0.0, 0.0, 0.0)
var minX = Double.MAX_VALUE
var minY = Double.MAX_VALUE
var maxX = -Double.MAX_VALUE
var maxY = -Double.MAX_VALUE
points.forEach {
minX = min(minX, it.x.toDouble()); maxX = max(maxX, it.x.toDouble())
minY = min(minY, it.y.toDouble()); maxY = max(maxY, it.y.toDouble())
}
return Rect(minX, minY, maxX - minX, maxY - minY)
}
Emit a ring as one moveTo-led, explicitly-closed subpath into into.
private fun emitRing(into: FLine, r: RawRing) {
into.moveTo(r.start.x, r.start.y)
r.segs.forEach { s ->
when (s) {
is RingLine -> into.lineTo(s.to.x, s.to.y)
is RingCubic -> into.cubicTo(s.c1.x, s.c1.y, s.c2.x, s.c2.y, s.to.x, s.to.y)
}
}
// Deliberately no Close node: `javaShapeToFLine` does not emit one either (it converts SEG_CLOSE
// into the closing lineTo, which `ringsOf` has already added), and FLine.tessellate's ring
// sanitiser drops the duplicate closing point either way. One representation, no special case.
}
Extract, classify and winding-normalise one glyph's rings.
The three steps that make the convention true:
1. flatten every ring, so nesting can be tested and area measured;
2. depth = how many other rings contain this ring's first vertex (point-in-polygon, so a ring
sharing a bounding box with a sibling is still classified correctly);
3. if sign(area) != expected(depth), reverse the cubic ring and re-flatten, so the polygon
and the outline never disagree.
private fun extractGlyph(shape: Shape, glyphCode: Int, chars: String, advance: Double, flatness: Double): Glyph {
val rings = ringsOf(shape)
if (rings.isEmpty()) {
return Glyph(glyphCode, chars, advance, emptyList(), FLine(), Rect(0.0, 0.0, 0.0, 0.0))
}
val flat = rings.map { flattenRing(it, flatness) }
val depth = IntArray(rings.size)
for (i in rings.indices) {
val probe = flat[i].firstOrNull() ?: continue
var d = 0
for (j in rings.indices) {
if (i == j || flat[j].size < 3) continue
if (pointInRing(flat[j], probe.x.toDouble(), probe.y.toDouble())) d++
}
depth[i] = d
}
val outline = FLine()
val contours = ArrayList<GlyphContour>(rings.size)
for (i in rings.indices) {
var ring = rings[i]
var pts = flat[i]
if (pts.size < 3) continue
val wantPositive = depth[i] % 2 == 0
if ((ringArea(pts) > 0.0) != wantPositive) {
ring = reverseRing(ring)
pts = flattenRing(ring, flatness)
}
emitRing(outline, ring)
contours.add(GlyphContour(pts, depth[i]))
}
val allPoints = contours.flatMap { it.points }
return Glyph(glyphCode, chars, advance, contours, outline, boundsOfPoints(allPoints))
}
Layout
enum class TextAlign { LEFT, CENTER, RIGHT }
Where a line's glyphs sit relative to the widest line of the layout.
class PlacedGlyph internal constructor
One glyph placed on a baseline, plus the handles that make it kinetic.
origin is where layout put it and never changes. Everything else is yours to move: this is the
object a Box.animate { } writes to frame by frame, and the reason TextLayout.toFLines and the
geometry bridges all re-read the transform rather than baking it.
The transform is translate(origin + offset) · translate(pivot) · rotate · scale · translate(-pivot),
so pivot is in the glyph's own coordinates and defaults to the centre of its visual bounds —
which means a bare rotation = 0.3 spins each letter about itself rather than about its baseline
corner, and looks like what you meant.
class PlacedGlyph internal constructor(
/**
* The outline being placed, shared with every other placement of the same glyph in the same
* font and size. Do not expect to own it.
*/
val glyph: Glyph,
/**
* Where layout put this glyph, in the layout's text space: on its line's baseline, with the
* alignment shift already in it. Fixed for the life of the placement — [offset] is the handle
* that moves it.
*/
val origin: Vec2,
/** Which of [TextLayout.lines] this glyph is on, counting from 0. */
val lineIndex: Int,
/**
* Index into the laid-out string of the first character this glyph renders. Line breaks count,
* so this indexes [TextLayout.text] rather than [TextLine.text].
*/
val charIndex: Int,
) {
var offset: Vec2 = Vec2(0f, 0f)
Extra translation, in text-space units, applied after layout.
var rotation: Double = 0.0
Rotation about pivot, radians, positive clockwise on screen (because y is down).
var scale: Vec2 = Vec2(1f, 1f)
Scale about pivot. Negative components mirror, and the geometry bridges cope.
var pivot: Vec2? = null
Rotation/scale centre in the glyph's own coordinates; null means the visual-bounds centre.
var color: Vec4? = null
Per-glyph colour override for TextLayout.toFLines; null means take the caller's colour.
val text: String get() = glyph.chars
The characters this placement renders — one, or several for a ligature.
val resolvedPivot: Vec2
The resolved pivot, in glyph coordinates.
val resolvedPivot: Vec2
get() = pivot ?: if (glyph.isBlank) Vec2((glyph.advance / 2).toFloat(), 0f)
else Vec2(glyph.bounds.cx.toFloat(), glyph.bounds.cy.toFloat())
fun transform(): AffineTransform
The affine this placement's geometry goes through. A fresh instance each call.
fun transform(): AffineTransform {
val t = AffineTransform()
t.translate(origin.x + offset.x.toDouble(), origin.y + offset.y.toDouble())
if (rotation != 0.0 || scale.x != 1f || scale.y != 1f) {
val p = resolvedPivot
t.translate(p.x.toDouble(), p.y.toDouble())
if (rotation != 0.0) t.rotate(rotation)
if (scale.x != 1f || scale.y != 1f) t.scale(scale.x.toDouble(), scale.y.toDouble())
t.translate(-p.x.toDouble(), -p.y.toDouble())
}
return t
}
val isUntransformed: Boolean
True when nothing has been asked of the handles, so the transform is a pure translation.
val isUntransformed: Boolean
get() = rotation == 0.0 && scale.x == 1f && scale.y == 1f && offset.x == 0f && offset.y == 0f
internal fun copyPlacement(): PlacedGlyph
internal fun copyPlacement(): PlacedGlyph {
val p = PlacedGlyph(glyph, Vec2(origin), lineIndex, charIndex)
p.offset = Vec2(offset)
p.rotation = rotation
p.scale = Vec2(scale)
p.pivot = pivot?.let { Vec2(it) }
p.color = color?.let { Vec4(it) }
return p
}
override fun toString(): String
override fun toString(): String = "PlacedGlyph('${text}' @ ${"%.1f".format(origin.x)},${"%.1f".format(origin.y)})"
}
class TextLine internal constructor
One baseline's worth of a layout.
There is one of these per \n-separated run of the laid-out string, including the empty ones: a
blank line is a TextLine with no glyphs and zero width, and it still takes up its baseline.
class TextLine internal constructor(
/** Which line this is, counting from 0, and the [PlacedGlyph.lineIndex] its glyphs carry. */
val index: Int,
/** This line's own characters, without the line break that ended it. */
val text: String,
/**
* This line's baseline in the layout's text space: `index` times the layout's
* [TextLayout.lineHeight], so the first line's baseline is at 0 and later lines are at
* increasing y, which is downwards.
*/
val baselineY: Double,
/**
* How far the pen travelled across this line, tracking between glyphs included and the gap
* after the last glyph excluded. It is not the inked width — an italic `f` can overhang it.
*/
val advanceWidth: Double,
/**
* The glyphs on this baseline, in reading order. They are the same objects as the matching
* stretch of [TextLayout.glyphs], so a handle set through either is set for both.
*/
val glyphs: List<PlacedGlyph>,
)
class TextLayout internal constructor
A laid-out string: glyphs on baselines, with handles.
Produced by layoutText / Typeface.layout. Immutable in its structure and mutable in its
handles, which is the split that matters — a layout is computed once and animated many times.
class TextLayout internal constructor(
/** The string that was laid out, line breaks and all. */
val text: String,
/** The typeface it was laid out in, which is where [ascent], [descent] and the glyphs come from. */
val typeface: Typeface,
/**
* Every placement in the layout, in reading order across all lines. This is what [eachGlyph]
* walks and what the geometry bridges read.
*/
val glyphs: List<PlacedGlyph>,
/** The lines, in order, one per `\n`-separated run of [TextLayout.text], empty lines included. */
val lines: List<TextLine>,
/** The extra advance after every glyph that was asked for, in text-space units. */
val tracking: Double,
/**
* Baseline-to-baseline distance in text-space units — the resolved value, `typeface.lineHeight`
* times the `lineSpacing` [layoutText] was given, not the multiplier.
*/
val lineHeight: Double,
/** Where each line sits relative to the widest one. */
val align: TextAlign,
) {
val ascent: Double get() = typeface.ascent
Positive, from the typeface.
val descent: Double get() = typeface.descent
Positive, from the typeface.
val advanceWidth: Double = lines.maxOfOrNull { it.advanceWidth } ?: 0.0
The widest line's advance width. Tracking is included; the trailing gap is not.
val layoutHeight: Double
Baseline-to-baseline of the first and last line, plus one line's ascent and descent.
val layoutHeight: Double
get() = if (lines.isEmpty()) 0.0 else (lines.last().baselineY - lines.first().baselineY) + ascent + descent
val layoutBounds: Rect
The em box: the rectangle layout reserved, ignoring what the glyphs actually inked.
val layoutBounds: Rect
get() = Rect(0.0, -ascent, advanceWidth, layoutHeight)
val bounds: Rect
Tight bounds of the inked geometry with every handle applied. Empty for whitespace-only text.
val bounds: Rect
get() {
var minX = Double.MAX_VALUE
var minY = Double.MAX_VALUE
var maxX = -Double.MAX_VALUE
var maxY = -Double.MAX_VALUE
var any = false
glyphs.forEach { g ->
if (g.glyph.isBlank) return@forEach
val t = g.transform()
g.glyph.contours.forEach { ring ->
ring.points.forEach { p ->
val d = t.transform(java.awt.geom.Point2D.Double(p.x.toDouble(), p.y.toDouble()), null)
minX = min(minX, d.x); maxX = max(maxX, d.x)
minY = min(minY, d.y); maxY = max(maxY, d.y)
any = true
}
}
}
return if (!any) Rect(0.0, 0.0, 0.0, 0.0) else Rect(minX, minY, maxX - minX, maxY - minY)
}
val inkedGlyphs: List<PlacedGlyph> get() = glyphs.filter { !it.glyph.isBlank }
Every glyph that inks something. Whitespace is in glyphs and not here.
val contourCount: Int get() = glyphs.sumOf { it.glyph.contourCount }
Total contour count across the layout — outer rings plus holes.
val holeCount: Int get() = glyphs.sumOf { it.glyph.holeCount }
Total hole count across the layout.
fun eachGlyph(block: PlacedGlyph.(Int) -> Unit): TextLayout {
Set the handles glyph by glyph, and hand the layout back for chaining.
The kinetic-typography entry point:
layout.eachGlyph { i -> rotation = 0.25 * sin(t + i * 0.6) }
fun eachGlyph(block: PlacedGlyph.(Int) -> Unit): TextLayout {
glyphs.forEachIndexed { i, g -> g.block(i) }
return this
}
fun resetHandles(): TextLayout
Reset every handle to its identity.
fun resetHandles(): TextLayout = eachGlyph {
offset = Vec2(0f, 0f); rotation = 0.0; scale = Vec2(1f, 1f); pivot = null; color = null
}
fun copyLayout(): TextLayout
A deep copy: same structure, independent handles.
Needed whenever two things animate the same string differently, and used by onCurve so that
bending text along a path does not destroy the layout you bent.
fun copyLayout(): TextLayout {
val copies = glyphs.map { it.copyPlacement() }
var k = 0
val newLines = lines.map { line ->
val take = copies.subList(k, k + line.glyphs.size).toList()
k += line.glyphs.size
TextLine(line.index, line.text, line.baselineY, line.advanceWidth, take)
}
return TextLayout(text, typeface, copies, newLines, tracking, lineHeight, align)
}
internal fun advanceCentreOf(g: PlacedGlyph): Double = g.origin.x + g.glyph.advance / 2.0
How far along a baseline this glyph's advance box is centred — the parameter onCurve uses.
override fun toString(): String
override fun toString(): String =
"TextLayout(${text.lines().size} line(s), ${glyphs.size} glyphs, ${"%.1f".format(advanceWidth)} wide, $typeface)"
}
fun layoutText
Lay a string out: AWT advances and kerning, plus tracking, line breaks and alignment.
Positions come from Font.layoutGlyphVector, so ligatures, bidi-neutral shaping and the font's
own kerning pairs are AWT's business rather than this file's. What this file adds is what AWT does
not do: \n handling, TextAlign, letter-spacing, and the PlacedGlyph handles.
@param tracking extra advance after every glyph, in text-space units — so it scales with the
typeface's size the same way the letters do. tracking = 0.05 * typeface.size is a readable
loose setting. The gap after the last glyph of a line is not counted in TextLayout.advanceWidth.
@param lineSpacing multiplier on the typeface's natural Typeface.lineHeight.
@param flatness polygon subdivision tolerance for Glyph.contours, in text-space units. The
default is a 400th of the size, which is finer than any raster you are likely to make and still
only a few hundred points per glyph.
body — 76 lines, from line 1186
fun layoutText(
text: String,
typeface: Typeface,
tracking: Double = 0.0,
lineSpacing: Double = 1.0,
align: TextAlign = TextAlign.LEFT,
flatness: Double = typeface.size / 400.0,
): TextLayout {
Typefaces.warm()
val font = typeface.awtFont
val lineHeight = typeface.lineHeight * lineSpacing
val rawLines = text.split("\n")
// Pass one: glyphs and per-line widths, all left-aligned at x = 0.
class Pending(val lineText: String, val placed: MutableList<PlacedGlyph>, val width: Double)
val pending = mutableListOf<Pending>()
var charBase = 0
rawLines.forEachIndexed { li, lineText ->
val placed = mutableListOf<PlacedGlyph>()
var width = 0.0
if (lineText.isNotEmpty()) {
val chars = lineText.toCharArray()
val gv = font.layoutGlyphVector(typographyFrc, chars, 0, chars.size, Font.LAYOUT_LEFT_TO_RIGHT)
val n = gv.numGlyphs
// getGlyphCharIndex, one at a time: the bulk getGlyphCharIndices takes a range and an
// output array, and there is nothing to gain from it at a glyph or two per letter.
val charIndices = IntArray(n) { gv.getGlyphCharIndex(it) }
for (i in 0 until n) {
val code = gv.getGlyphCode(i)
val pos = gv.getGlyphPosition(i)
val advance = gv.getGlyphMetrics(i).advanceX.toDouble()
val ci = charIndices.getOrElse(i) { i }
val nextCi = charIndices.getOrElse(i + 1) { lineText.length }
val src = lineText.substring(
ci.coerceIn(0, lineText.length),
max(ci + 1, nextCi).coerceIn(0, lineText.length)
)
val g = Typefaces.cachedGlyph(font, code) {
// getGlyphOutline(i, x, y) places the glyph's origin at (x, y), so passing the
// negated layout position lifts it back into its own frame — which is what makes
// the cache sharable between every string that uses this glyph.
val shape = gv.getGlyphOutline(i, (-pos.x).toFloat(), (-pos.y).toFloat())
extractGlyph(shape, code, src, advance, flatness)
}
placed.add(
PlacedGlyph(g, v2(pos.x + i * tracking, 0.0), li, charBase + ci)
)
}
// The pen after the last glyph, minus the trailing tracking gap nobody asked for.
width = gv.getGlyphPosition(n).x + max(0, n - 1) * tracking
}
pending.add(Pending(lineText, placed, width))
charBase += lineText.length + 1
}
// Pass two: alignment shift and baseline y, which need the widest line.
val widest = pending.maxOfOrNull { it.width } ?: 0.0
val allGlyphs = mutableListOf<PlacedGlyph>()
val outLines = mutableListOf<TextLine>()
pending.forEachIndexed { li, p ->
val shift = when (align) {
TextAlign.LEFT -> 0.0
TextAlign.CENTER -> (widest - p.width) / 2.0
TextAlign.RIGHT -> widest - p.width
}
val baselineY = li * lineHeight
val moved = p.placed.map { g ->
PlacedGlyph(g.glyph, v2(g.origin.x + shift, baselineY), li, g.charIndex)
}
allGlyphs.addAll(moved)
outLines.add(TextLine(li, p.lineText, baselineY, p.width, moved))
}
return TextLayout(text, typeface, allGlyphs, outLines, tracking, lineHeight, align)
}
fun Typeface.layout
layoutText with the typeface first — reads better when you keep a typeface around.
fun Typeface.layout(
text: String,
tracking: Double = 0.0,
lineSpacing: Double = 1.0,
align: TextAlign = TextAlign.LEFT,
): TextLayout = layoutText(text, this, tracking, lineSpacing, align)
Text on a curve
fun TextLayout.onCurve
Bend a layout onto an FLine by arc length.
Each glyph's advance-box centre is looked up at distance startOffset + centreX along path with
FLine.cursor()'s arc-length table (FLinesAndJavaShapes.Cursor, which is the same machinery
sampleByDistance and byDashing use, so spacing is even in distance and not in curve
parameter). The glyph is then rotated to the tangent there and pushed along the normal by its own
baseline y, so a multi-line layout follows the curve in bands rather than collapsing onto it.
Returns a copy — the layout you passed in is untouched, so you can bend the same string along
several paths, or animate startOffset without re-laying-out.
@param startOffset where the first baseline unit lands, in distance along path.
@param align LEFT starts at startOffset; CENTER and RIGHT add the slack between the path's
length and the layout's width, so text centres or ends on the path.
@param flip rotate every glyph by π, for text on the inside of a circle.
@param clampToPath when true (the default) a glyph past either end of the path is placed at the
end; when false it is extrapolated along the end tangent, which keeps letters marching off the
end of an open curve instead of piling up on the last point.
body — 57 lines, from line 1295
fun TextLayout.onCurve(
path: FLine,
startOffset: Double = 0.0,
align: TextAlign = this.align,
flip: Boolean = false,
clampToPath: Boolean = true,
): TextLayout {
val out = copyLayout()
if (path.nodes.isEmpty()) return out
val cursor = path.cursor()
val length = cursor.lengthD().toDouble()
if (length <= 0.0) return out
val slack = length - advanceWidth
val base = startOffset + when (align) {
TextAlign.LEFT -> 0.0
TextAlign.CENTER -> slack / 2.0
TextAlign.RIGHT -> slack
}
var lastTangent = Vec2(1f, 0f)
out.glyphs.forEach { g ->
val centre = g.origin.x + g.glyph.advance / 2.0
val want = base + centre
val d = want.coerceIn(0.0, length)
val p = cursor.setD(d.toFloat()).position()
val t = cursor.tangent()
val tan = if (t != null && (abs(t.x) > 1e-7f || abs(t.y) > 1e-7f)) {
val l = hypot(t.x.toDouble(), t.y.toDouble())
Vec2((t.x / l).toFloat(), (t.y / l).toFloat())
} else lastTangent
lastTangent = tan
// The overshoot a non-clamping caller asked for: keep walking along the end tangent.
val over = if (clampToPath) 0.0 else (want - d)
var angle = atan2(tan.y.toDouble(), tan.x.toDouble())
if (flip) angle += Math.PI
// Local +y maps to rotate(angle) * (0,1) = (-sin, cos) = the left normal, so the glyph's own
// baseline y is already handled by the rotation; what needs adding here is the *line's*
// baseline offset, because that is part of `origin` and not of the glyph.
val nx = -sin(angle)
val ny = cos(angle)
val pivot = Vec2((g.glyph.advance / 2.0).toFloat(), 0f)
val targetX = p.x + nx * g.origin.y + tan.x * over
val targetY = p.y + ny * g.origin.y + tan.y * over
g.pivot = pivot
g.rotation = angle
g.offset = v2(
targetX - g.origin.x - pivot.x,
targetY - g.origin.y - pivot.y
)
}
return out
}
fun textCircle(radius: Number, centre: Vec2 = Vec2(0f, 0f), clockwise: Boolean = true, segments: Int = 96): FLine
A circle as an FLine, clockwise from the top — the path onCurve wants for a roundel.
In text space (y down) clockwise = true reads left-to-right along the top of the circle, which
is the usual setting for a badge or a seal; false plus flip = true reads along the bottom.
fun textCircle(radius: Number, centre: Vec2 = Vec2(0f, 0f), clockwise: Boolean = true, segments: Int = 96): FLine {
val r = radius.toDouble()
val dir = if (clockwise) 1.0 else -1.0
return fline {
for (i in 0..segments) {
val a = -Math.PI / 2 + dir * 2.0 * Math.PI * i / segments
val x = centre.x + r * cos(a)
val y = centre.y + r * sin(a)
if (i == 0) moveTo(x, y) else lineTo(x, y)
}
}
}
fun textArc(width: Number, height: Number, from: Vec2 = Vec2(0f, 0f)): FLine
A cubic arch through three points — a quick path for onCurve when you do not want to draw one.
Sags/rises by height at its midpoint; negative arches upward in text space.
fun textArc(width: Number, height: Number, from: Vec2 = Vec2(0f, 0f)): FLine {
val w = width.toDouble()
val h = height.toDouble()
return fline {
moveTo(from.x, from.y)
cubicTo(
from.x + w / 3.0, from.y + h * 4.0 / 3.0,
from.x + w * 2.0 / 3.0, from.y + h * 4.0 / 3.0,
from.x + w, from.y.toDouble()
)
}
}
fun FLine.arcLength(): Double = if (nodes.isEmpty()) 0.0 else cursor().lengthD().toDouble()
Total arc length of a path, the number onCurve's startOffset is measured in.
Small conveniences
fun wrapText(text: String, typeface: Typeface, maxWidth: Double, tracking: Double = 0.0): String
The classic single-line word-wrap, in text-space units.
Wraps on spaces only (no hyphenation, no CJK line-breaking) and measures with the real layout, so
the result is correct for the tracking and kerning you are going to use. Existing \n are kept.
fun wrapText(text: String, typeface: Typeface, maxWidth: Double, tracking: Double = 0.0): String {
require(maxWidth > 0) { "maxWidth must be positive, got $maxWidth" }
fun widthOf(s: String) = if (s.isEmpty()) 0.0 else layoutText(s, typeface, tracking).advanceWidth
val out = StringBuilder()
text.split("\n").forEachIndexed { li, para ->
if (li > 0) out.append('\n')
var line = StringBuilder()
para.split(" ").filter { it.isNotEmpty() }.forEach { word ->
val candidate = if (line.isEmpty()) word else "$line $word"
if (widthOf(candidate) <= maxWidth || line.isEmpty()) {
line = StringBuilder(candidate)
} else {
out.append(line).append('\n')
line = StringBuilder(word)
}
}
out.append(line)
}
return out.toString()
}
fun textWidth(text: String, typeface: Typeface, tracking: Double = 0.0): Double
Advance width of a string without building a layout you keep.
fun textWidth(text: String, typeface: Typeface, tracking: Double = 0.0): Double =
layoutText(text, typeface, tracking).advanceWidth
fun GlyphContour.resample(spacing: Double): List<Vec2>
Even samples along a ring, by arc length — the primitive the point-cloud bridge is built from.
Kept here rather than in typography_field.kt because GlyphContour owns its own resampling and
because it is the right way to compare two rings' shapes at equal spacing.
fun GlyphContour.resample(spacing: Double): List<Vec2> {
require(spacing > 0) { "spacing must be positive, got $spacing" }
if (points.size < 2) return points.map { Vec2(it) }
val out = mutableListOf<Vec2>()
var carry = 0.0
for (i in points.indices) {
val a = points[i]
val b = points[(i + 1) % points.size]
val seg = dist(a, b)
if (seg <= 1e-9) continue
var s = carry
while (s < seg) {
val t = s / seg
out.add(v2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t))
s += spacing
}
carry = s - seg
}
return out
}
fun GlyphContour.distanceTo(p: Vec2): Double
Straight-line distance from p to this ring's boundary. Always non-negative.
fun GlyphContour.distanceTo(p: Vec2): Double {
var best = Double.MAX_VALUE
for (i in points.indices) {
val a = points[i]
val b = points[(i + 1) % points.size]
best = min(best, pointSegmentDistance(p.x.toDouble(), p.y.toDouble(), a, b))
}
return if (best == Double.MAX_VALUE) 0.0 else sqrt(best)
}
internal fun pointSegmentDistance(px: Double, py: Double, a: Vec2, b: Vec2): Double
Squared distance from (px, py) to segment ab. Squared, because the callers sort before rooting.
internal fun pointSegmentDistance(px: Double, py: Double, a: Vec2, b: Vec2): Double {
val ax = a.x.toDouble()
val ay = a.y.toDouble()
val vx = b.x.toDouble() - ax
val vy = b.y.toDouble() - ay
val wx = px - ax
val wy = py - ay
val vv = vx * vx + vy * vy
val t = if (vv <= 1e-18) 0.0 else ((wx * vx + wy * vy) / vv).coerceIn(0.0, 1.0)
val dx = wx - t * vx
val dy = wy - t * vy
return dx * dx + dy * dy
}