text_on_path 0.1.0
text_on_path: ^0.1.0 copied to clipboard
Text along any curve, a circle for a seal or any Path, with cursive scripts kept joined. Arabic, Persian and Urdu stay connected; Devanagari and Bengali keep their headline.
text_on_path #
Text along any curve in Flutter, a circle for a seal or any Path, with cursive scripts kept joined: Arabic, Persian
and Urdu letters stay connected; Devanagari and Bengali keep an unbroken headline.
Text-on-path packages turn each glyph (or each character) whole to the curve. A joining stroke runs from one letter into the next, so when the two turn by different angles it splits: a gap on the outside of a bend, an overlap on the inside. Bending the whole run instead keeps joins closed but stretches every letter on the outside of a bend and squashes it on the inside. text_on_path keeps letters rigid and bends only the rows they join on.
Try it in the browser: rahiche.github.io/text_on_path: bend a curve and watch the joins, draw your own, type your own text, and see the seals.
Scripts #
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Seals: each phrase twice round a ring, fitted by kashida, tracking or word spacing.
Use #
flutter pub add text_on_path
import 'package:text_on_path/text_on_path.dart';
TextOnPath(
'الحروف تبقى متصلة',
curve: PathCurve.arc(const Offset(160, 160), 120), // or PathCurve.fromPath(path)
style: const TextStyle(fontSize: 32),
)
TextOnPath fills the space it's given; the curve is in its coordinates. Text stands on the curve's left: on a
clockwise circle it faces out, on an anticlockwise one it faces in. position places the middle of the text along the
curve, as a fraction of its length.
- Fit a length:
fit: TextOnPathFit.kashida(Arabic's own way: one join a word drawn out, where a calligrapher would),tracking(joins drawn out rather than opened) orspacing(between words), tofitLength. - Compare:
mode: TextOnPathMode.rigidturns each cluster whole, as other packages do (but shaped);warpbends the whole run, as SVG'stextPath method="stretch"would. - Sharp at any zoom: by default the text is drawn from an atlas drawn again for the scale it's shown at (any
TransformorInteractiveViewerabove it, times the screen's pixel ratio), and from traced outlines past a memory budget.rendering: TextOnPathRendering.vectoralways uses outlines.
Selection #
Inside a SelectionArea, curved text selects like any other: drag across it, double-click a word, long-press on a
phone for handles, and copy. A pointer is carried back to the straight line the text was shaped as, and the paragraph
finds the position there, so right-to-left text, clusters and words come out right. The highlight bends with the curve,
and the handles turn with it.
SelectionArea(
child: TextOnPath('الحروف تبقى متصلة', curve: curve, style: style),
)
Left, a mouse drag from the start of the text; right, a long press on a word, with handles.
As vectors: SVG and PDF #
final shaped = await ShapedText.layout('शिरोरेखा जुड़ी रहती है', style);
final outlines = await TextOutlines.trace(shaped);
final placement = TextOnPathPlacement(shaped, curve);
final svg = placement.toSvg(outlines); // or placement.toPdf(outlines), placement.outlinePaths(outlines)
ShapedText shapes the text once; TextOnPathPlacement places it on a curve (cheap: once a frame if the curve moves)
and paints it, from an atlas or outlines; TextOnPathMetrics.of(placement) measures it.
How it works #
- Shape once. The whole run is shaped by Flutter's paragraph engine, so every script comes out right: Arabic joining forms, Indic conjuncts and vowel signs, Nastaliq's slope.
- Draw each cluster alone. Each grapheme cluster is drawn with the rest of the run transparent, so it keeps its exact shape and place, overhanging tails and all.
- Find the joining rows. Wherever ink crosses the boundary between two clusters, that's a join; the rows most joins cross in are the script's connection line: the Arabic baseline stroke, the Indic headline.
- Bend only those rows. Each cluster is a triangle mesh (
Canvas.drawVertices). Points in the joining rows follow the curve; points away from them stand rigid with their letter; one eases into the other over 0.18 em. - Draw joins out, or pull them shut. Where rigid letters land apart along the curve, a flat join is drawn out with its own cross-section, as a tatweel would be; a sloping one is pulled shut, the pull fading out over 0.3 em.
Three Devanagari clusters, flat (left) and on a tight circle (right). Each cluster's grid keeps its own colour; the joining rows (teal) bend with the curve and run on from cluster to cluster, while the rest of each grid stays straight and turns whole.
How well it works #
At 48 px on a circle of radius 3 em, letters facing out. Joins: the area, in px², between where one cluster puts a join's boundary and where its neighbour puts it, over the phrase. Distortion: how far ink moved from the nearest rigid placement of its letter, RMS, in px. Both measured through the mesh as drawn.
| Sample | Joins, rigid | Joins, text_on_path | Distortion, plain warp | Distortion, text_on_path |
|---|---|---|---|---|
| Arabic (Noto Naskh Arabic) | 13 | 0 | 0.43 | 0.22 |
| Persian (Noto Naskh Arabic) | 7.8 | 0 | 0.29 | 0.18 |
| Urdu (Noto Nastaliq Urdu) | 62 | 0.1 | 1.32 | 0.43 |
| Hindi (Noto Sans Devanagari) | 135 | 0 | 1.51 | 0.28 |
| Bengali (Noto Sans Bengali) | 92 | 0 | 2.06 | 0.44 |
| Latin script (Dancing Script) | 51 | 0.1 | 0.49 | 0.18 |
Rigid placement's defects are all gaps outside a bend and all overlaps inside one. text_on_path closes them, with between a fifth and two thirds of a plain warp's distortion. Fitted round a whole ring, rigid placement can only open joins (221 px² on an Arabic seal); text_on_path draws them out. The inside of a bend, an S-curve and the seals are in doc/measurements.md, and the sharpness study in doc/sharpness.md.
The example app #
example/ is the gallery, and runs on the desktop and phones too. It shows Arabic, Persian, Urdu, Hindi, Bengali and a Latin script hand on a circle and an S-curve, rigid
placement beside joined, measured, with a loupe on the join rigid placement opens widest; seals fitted by kashida,
tracking or word spacing; a zoomable view that saves SVG and PDF; and selection. Its fonts are bundled under the SIL Open Font
License.
Limits #
- On the inside of a bend, letters are spaced along whichever is nearer the inside, the baseline or the joining rows, so joins are never squeezed. For the Indic headline, well above the baseline, that makes Hindi and Bengali about a quarter longer inside a circle of radius 3 em.
- Flutter doesn't hand back glyph outlines, so
TextOutlinestraces them from a fine raster: within a few hundredths of a pixel of the font's edges at the text's size, but polygons, not the font's own curves. - Kashida draws a join out with the stroke's own cross-section rather than inserting U+0640 and shaping again, so a font's special kashida forms don't appear.
- The joining rows are found from the run's pixels;
connection: ConnectionLine.between(...)sets them by hand.
License #
Apache 2.0. The example's fonts are under the SIL Open Font License, Version 1.1.






