Cross Platform Text Rendering

This is meant to be a list of organized topics for everyone who wants to include cross-platform text rendering in their program.

Cross Platform Text Rendering

Let's start with how a RenderText function signature might look like.

We might start with something simple like this:

void RenderText(float x, float y, char *str, uint32_t length);

But since we want to support both Unix and Windows, we should support both UTF8 and UTF16.

typedef enum TextEncoding {
	TE_UTF8,
	TE_UTF16,
	TE_UTF32
} TextEncoding;

void RenderText(float x, float y, char *str, size_t length, TextEncoding encoding);

Let's place the string data in a single struct:

typedef struct String {
	unsigned char *data, 
	uint32_t length;
	TextEncoding encoding;
} String;

(NOTE: That's not actually how you would normally handle encoding, this is just for simplicity sake)

We also need the font:

void RenderText(float x, float y, String string, String font);

But font is more than just a string, let's add a FontDescriptor struct:

typedef enum FontStyle {
	FS_Normal,
	FS_Italic,
	FS_Oblique
} FontStyle;

typedef enum TextDecoration {
	TD_None,
	TD_UnderLine,
	TD_StrikeThrough,
	TD_OverLine
} TextDecoration;

typedef struct Color {
	float r, g, b, a;
} Color;

struct FontDescriptor {
	String familyName;
	float size;
	int weight;
	FontStyle style;
	TextDecoration decoration;
	Color color;
	Color background;
} FontDescriptor;

void RenderText(float x, float y, String string, FontDescriptor font);

We also need to talk about fallback fonts.
The problem is that a given font might not be installed on the user's machine, and that fonts are not required to implement all codepoints.

typedef struct StringList {
	...
};

typedef struct FontDescriptor {
	String familyName;
	float size;
	int weight;
	FontStyle style;
	TextDecoration decoration;
	Color color;
	Color background;
	StringList fallbacks;
} FontDescriptor;

(NOTE: insert your preferred implementation of StringList. And also there are more nuances when dealing with fallbacks. e.g.: font-stretching might be needed. Also if the font family exists but not with the weight and style requested, should you fallback to regular, or to the next one on the list? Or maybe you want to fallback when a font is missing, but just render a placeholder for each missing codepoint.)

Now let's add text wrapping.
Once we have text wrap, just an x, y is not enough anymore and we need a bounding box.

typedef struct TextWrapping {
	TW_NoWrap,
	TW_Wrap
} TextWrapping;

typedef struct Rect {
	float left, right, top, bottom;
} Rect;

void RenderText(Rect boundingBox, String string, FontDescriptor font, TextWrapping wrap);

(NOTE: wrapping is actually slightly more nuanced than wrap/no-warp, e.g.: how do you handle a single word that is larger than the bounding box's width?)

We are still missing a couple of things, so let's quickly add them:

typedef enum TextAlignment {
	TA_Start,
	TA_End,
	TA_Center,
	TA_Justified
} TextAlignment;

typedef enum TextDirection {
	TD_Ltr,
	TD_Rtl
} TextDirection;

void RenderText(Rect boundingBox, String string, FontDescriptor font, TextWrapping wrap, TextDirection dir, TextAlignment align);

And let's not forget tab-size, and line-spacing.

void RenderText(
	Rect boundingBox, 
	String string, 
	FontDescriptor font, 
	TextWrapping wrap, 
	TextDirection dir, 
	TextAlignment align,
	float tabSize,
	float lineSpacing);

Styling is actually not done on the entire text, but rather on chunks we call text-runs.

Let's fix that:

typedef struct TextRun {
	uint32_t start;
	uint32_t length;
	FontDescriptor font;
} TextRun;

typedef struct TextRunList {
	...
} TextRunList;

void RenderText(
	Rect boundingBox,
	String string,
	TextRunList runs,
	TextWrapping wrap, 
	TextDirection dir, 
	TextAlignment align,
	float tabSize,
	float lineSpacing);

(NOTE: if you assume that the text-runs are exclusives and exhaustive, you don't need the start, only length)

And finally you might want to add a selection.

typedef TextSelection {
	uint32_t start;
	uint32_t length;
} TextSelection;

void RenderText(
	Rect boundingBox,
	String string,
	TextRunList runs,
	TextWrapping wrap, 
	TextDirection dir, 
	TextAlignment align,
	float tabSize,
	float lineSpacing,
	TextSelection selection);

Now that we have a function signature, let's see what it takes to implement it.

Text rendering is divided into several steps, though the lines between them tend to get blurry.

  • Font File Matching
  • Font File Parsing
  • String Decoding
  • Font Shaping
  • Layout Algorithms
  • Glyph Rasterizing
  • Drawing

Font File Matching

First, we need to find and open the font files. Different weights and styles are stored in different files. If the file couldn't be found, we go to the next one down the list of fallback fonts. If you are using a single font file, this step can be avoided but simply embedding the font file in your executable.

Font File Parsing

Font files contain both the instruction on how to map codepoint stream to glyph stream, and instructions on how to rasterize the glyphs. Note that once you found and parse the file, you probably want to cache the mapping from the requested font to a font object. There are 3 main font formats: TrueType, Microsoft's OpenType and Apple's AAT. OpenType has a list of many features, including even full wasm instructions: https://en.wikipedia.org/wiki/List_of_typographic_features#OpenType_typographic_features https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist

String Decoding

Turning the string from a stream of characters into a stream of codepoints.

Font Shaping

Using the font object we now map the stream of codepoints to a stream of glyphs. When a glyph is missing we can turn again to the fallback fonts.
Font shaping takes a single run as input and return a list of glyphs.
Each glyph contains the eventual font, the glyph-id, an (x, y)-offset, and an (x, y)-advance.

Layout Algorithms

This is when our text-alignment, paragraph-alignment, text-direction and wrapping parameters come into play. This is also when we decide where each run will be positioned relative to the previous run, and where we add line breaks, and tabs.
If we have a selection highlight this is the time to compute the rectangles that will go into drawing it.

Glyph Rasterizing

This step is not dependent on the layout algorithms, and can be performed after the font shaping. For each font, glyph-id and size, we need to perform rasterization, i.e. creating the bitmap colorless version of it. We allocate a region in a glyph atlas, and we cache the mapping from the glyph-id + font + size to the allocated region. Beware! some of the glyph rasterizing algorithms are sadly patent-protected!

Drawing

If we want to add selection highlight, we first draw the highlight rectangles.
Now for each run we create a texture, by using the previous two steps, and we send it to the GPU with a position, a color and possibly a background color. There are two types of font anti-aliasing: Grayscale and ClearType.
Finally we add text decorations if any are needed.

Libraries

  • Fontconfig - for the font matching
  • stb_truetype - for parsing, shaping and rasterizing TrueType, does not handle hinting.
  • Harfbuzz - for parsing and shaping TrueType, OpenType and AAT.
  • FriBidi - for bi-directional layout algorithms
  • Pango - for additional layout algorithms
  • FreeType - for glyph rasterizing
  • Skia - for glyph rasterizing