Skip to main content

Glyph renderers

The library does not draw. TextRenderer lays out the text, then calls an IGlyphRenderer that you write, once for each glyph. Your renderer gets each glyph outline as a sequence of lines and Bézier curves, already scaled, positioned and, in vertical text, rotated. You turn those calls into drawing commands for your target: an SVG path, a PDF content stream, a System.Drawing path, a GPU mesh, and so on.

Render text​

using PinataLabs.Typography;

TextOptions options = new(font) { Origin = new(10, 10) };

// Static form:
TextRenderer.RenderTextTo(renderer, "Hello, world!", options);

// Instance form, to render more than one string to the same renderer:
TextRenderer textRenderer = new(renderer);
textRenderer.RenderText("First line", options);

// Extension method on the renderer:
renderer.Render("Hello", options);

All coordinates are in pixels at TextOptions.Dpi. The y-axis points down: the top of the text has smaller y-values than the bottom.

The order of the calls​

For one call to RenderTextTo, the library calls your renderer in this order:

BeginText(bounds of all the text)
for each glyph:
BeginGlyph(bounds of the glyph, parameters) -> return true to get the outline
SetColor(color) (IColorGlyphRenderer, color layers only)
for each closed contour of the outline:
BeginFigure()
MoveTo(start point)
LineTo / QuadraticBezierTo / CubicBezierTo (one or more)
EndFigure()
EnabledDecorations()
SetDecoration(...) (once for each decoration you asked for)
EndGlyph() (always, even when BeginGlyph returned false)
EndText()
MethodWhat to do
BeginTextPrepare for a new piece of text. bounds is the ink box of all the glyphs.
BeginGlyphStart a glyph. Return false to skip its outline and decorations, for example when the glyph is outside the visible area, or when you already have the glyph in a cache.
BeginFigureStart a new closed contour.
MoveToMove to the first point of the contour.
LineToDraw a straight line to the point.
QuadraticBezierToDraw a quadratic curve. TrueType outlines use these.
CubicBezierToDraw a cubic curve. CFF and CFF2 outlines use these.
EndFigureClose the contour.
EnabledDecorationsReturn the decorations that you want for this glyph.
SetDecorationDraw one decoration line.
EndGlyphFinish the glyph.
EndTextFinish the text.

Fill the contours with the non-zero winding rule. Outer contours and the holes inside them (as in o and A) go in opposite directions.

A whitespace glyph gets BeginGlyph and EndGlyph, and its decorations, but no figures.

Example: an SVG renderer​

This renderer writes one SVG <path> element for each glyph, and a <line> for each decoration:

using System.Globalization;
using System.Numerics;
using System.Text;
using PinataLabs.Typography;

public sealed class SvgGlyphRenderer : IGlyphRenderer
{
private readonly StringBuilder svg = new();
private readonly StringBuilder path = new();
private TextDecorations decorations;

public string ToSvg(float width, float height)
=> $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{F(width)}\" height=\"{F(height)}\">{this.svg}</svg>";

public void BeginText(in FontRectangle bounds) { }

public void EndText() { }

public bool BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters)
{
this.path.Clear();
this.decorations = parameters.TextRun.TextDecorations;
return true;
}

public void BeginFigure() { }

public void MoveTo(Vector2 point) => this.path.Append($"M{F(point.X)} {F(point.Y)}");

public void LineTo(Vector2 point) => this.path.Append($"L{F(point.X)} {F(point.Y)}");

public void QuadraticBezierTo(Vector2 secondControlPoint, Vector2 point)
=> this.path.Append($"Q{F(secondControlPoint.X)} {F(secondControlPoint.Y)} {F(point.X)} {F(point.Y)}");

public void CubicBezierTo(Vector2 secondControlPoint, Vector2 thirdControlPoint, Vector2 point)
=> this.path.Append(
$"C{F(secondControlPoint.X)} {F(secondControlPoint.Y)} " +
$"{F(thirdControlPoint.X)} {F(thirdControlPoint.Y)} {F(point.X)} {F(point.Y)}");

public void EndFigure() => this.path.Append('Z');

public void EndGlyph()
{
if (this.path.Length > 0)
{
this.svg.Append($"<path d=\"{this.path}\" fill=\"black\"/>");
}
}

public TextDecorations EnabledDecorations() => this.decorations;

public void SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness)
{
// start and end are on the top edge of the line, so move down by half the thickness.
float y = start.Y + (thickness / 2);
this.svg.Append(
$"<line x1=\"{F(start.X)}\" y1=\"{F(y)}\" x2=\"{F(end.X)}\" y2=\"{F(y)}\" " +
$"stroke=\"black\" stroke-width=\"{F(thickness)}\"/>");
}

private static string F(float value) => value.ToString("0.##", CultureInfo.InvariantCulture);
}

Use it like this:

TextOptions options = new(font) { Origin = new(10, 10), WrappingLength = 380 };
const string text = "Hello from PinataLabs.Typography!";

FontRectangle size = TextMeasurer.MeasureSize(text, options);

SvgGlyphRenderer renderer = new();
TextRenderer.RenderTextTo(renderer, text, options);
File.WriteAllText("hello.svg", renderer.ToSvg(size.Width + 20, size.Height + 20));

Glyph parameters​

BeginGlyph gets a GlyphRendererParameters value. It identifies the glyph and its context:

PropertyMeaning
GlyphIdThe glyph's index in its font.
CodePointThe code point that the glyph came from.
FontThe full name of the font that the glyph came from, in upper case, for example OPEN SANS REGULAR. A glyph from a fallback font has the name of that font.
FontStyleThe style of that font.
PointSizeThe font size in points.
DpiThe DPI of the layout.
GlyphTypeStandard, Fallback (no font had the character) or ColrLayer (one layer of a color glyph).
GlyphColorThe color of a ColrLayer glyph.
LayoutModeHorizontal, Vertical or VerticalRotated.
TextRunThe TextRun that the glyph is in.

GlyphRendererParameters is an equatable value. Two glyphs with equal parameters have the same outline, apart from their position. Use it as a cache key: draw the outline once, keep it, and on later glyphs return false from BeginGlyph and draw the cached shape at the new position. The bounds passed to BeginGlyph give that position.

The parameters do not include HintingMode. If you render the same font with and without hinting, keep a separate cache for each.

Decorations​

Underline, strike-out and overline come from the TextRun that a glyph is in. The library asks your renderer which ones it wants, with EnabledDecorations, for each glyph. The usual answer is the decorations of the run:

public TextDecorations EnabledDecorations() => this.currentParameters.TextRun.TextDecorations;

You can return other values, for example to underline links that you marked with a custom TextRun.

Then SetDecoration is called once for each decoration, with:

  • start and end: the ends of the line along one edge, in pixels. For horizontal text they are on the top edge of the line, and the line extends thickness pixels down from them.
  • thickness: the thickness of the line, from the font's metrics.

Each glyph gets its own segment of the line, including spaces. Draw the segments next to each other and they join into one line. You can also join segments that touch into one longer line.

Thread safety​

A TextRenderer and your IGlyphRenderer hold state, so use one of each on one thread at a time. Font, FontFamily and a filled FontCollection can be shared: many threads can measure and render with the same font at once, each with its own TextOptions and renderer. Add all fonts to a collection before you share it.