Writing your own Rich Text system

Writing your own Rich Text system.

This guide aims to take you through the steps and thought process of creating a module for parsing text and building UI for rich text purposes. Many steps of this guide may be skipped, however for educational purposes, it is recommend you follow all of it.


Introduction

Hey, I’m Stef, I’m a Roblox game developer, and a computer science university student. I’ve worked on a couple of games during my time on Roblox, and in a few of those, I have felt the need to spice up my UI, typically dialogue, through some custom rich text effects.

Having implemented such a system from scratch multiple times, I am all too familiar with how lost one can feel, when faced with the task.

In this guide I plan to walk you through the steps and thought process, which will ultimately lead to you creating your very own (modular) rich text system

I will be covering the following:

  1. Terminology
  2. Parsing Text
    • Defining Syntax
    • String patterns
    • Stack data structure
    • Implementation
    • Error handling
  3. Building UI
    • Creating Labels
  4. Implementing an example tag
    • Data structure
    • Wrapping up

I hope you will indulge me by sticking with me throughout this guide, and I hope you learn something valuable. Let’s get started.


Terminology

Throughout this guide I will be using some terminology to more effectively communicate the process. I expect you to be familiar with common Luau terminology, however I will elaborate on some terms here.

Term Definition
Tag A series of characters that denotes a change in appearance of the rich text, rather than actual textual content.
Pattern A series of characters that denotes a special syntax or pattern, similar to Regular Expressions.

Parsing Text

Defining Syntax

In order to create our rich text system, we need to first be able to parse our input text, and identify what is tags and what is literal text. To do this, we need to first denote how we want our syntax to be.

We may get some inspiration in this regard from Roblox’ own rich text. If you have worked with XML or HTML, this should be familiar to you.

If we look at the documentation for Roblox’ rich text markup, we see that tags are denoted by wrapping the name of the tag in less-than and greater-than signs. For demonstration purposes, I will be using the common <b> tag, which typically denotes boldness.

We may also note that tags are separated into opening and closing tags, where closing tags are denoted by prefixing the name of the tag with a forward slash. Using the bold tag as an example again, a closing tag may look like this: </b>.

All opening tags must be accompanied by a closing tag, and all text between a pair of tags will be formatted.

So if we want to create some bold text using Roblox’ rich text, we would write the following:

<b>this text is bold</b>

Additionally, some tags can have attributes. Taking an example from the documentation, the <font> tag has various attributes, one of which being color, which allows us to change the color of the text.
These attributes are denoted like so:

<font color="#00FF00">this text is green</font>

Take note that we only define attributes in opening tags.

And that’s it! It may be simple, but this syntax is all we need.
If you wish, you can change the syntax within your own implementation, however I will stick to what we have outlined here.

String patterns

Now that we have defined our syntax, we can move on to the interesting part. So let’s take a step back for a moment and consider what our goal is.

For now we aren’t concerned with how we will actually render our text, but only parsing our input text so we are able to process it. To do this we need to be able to separate tags from regular text. And our best tool available to achieve this is string patterns.

If we can prepare a string pattern that can identify tags for us, we can easily go through any inputted string and isolate all tags.

And we can! Using the string patterns reference we have everything we need. So let’s start preparing our pattern.

First of all, we know any tag has to start with < and end with >, so that will be our starting point.

"<>"

We also need to account for the characters inside the tag, so let’s use .-, which denotes any amount of any character, but finds the match with the fewest.

"<.->"

With this, we can find any instance within a text, where some (or none) characters are wrapped in less-than and greater-than signs!

But we can do better than that!
Let’s consider what will actually be inside the tags.

The first thing inside any tag, will be the name of the tag itself. For simplicity, we will limit these names to be alphabetical only, and we will make it case-insensitive.
We can achieve this using the pattern %a+, which denotes 1 or more, upper- or lower-case, letters.
Replacing our naive solution with this, we have:

"<%a+>"

Next up, it’s time to account for closing tags too! Remember that these are denoted by a prefixed forward slash. We may use the ? quantifier to denote that we expect 1 or 0 preceding forward slashes.

"</?%a+>"

We’re almost there, but not quite yet! We also need to consider the possibility of there being attributes defined.

This is a good time for me to clarify something from the terminology. I compare string patterns to Regular Expressions, however the truth is, string patterns are a lot less powerful, and support a lot fewer features. Because of this, we are going to split our pattern into two separate patterns here.

But first we need to finish the one we are working on right now.

We know that anything after the name of the tag, must be attributes, otherwise the tag is invalid. To handle this, we use a capture group.

"</?%a+(.-)>"

This pattern, when used with string.match, will allow us to not only verify that the tag (to some extent) adheres to our syntax, but will also let us grab the substring, which is the attributes.

We can then match another pattern onto this substring, in order to verify that the tag is indeed valid.

This is possible with a pattern that matches just a single attribute, so let’s make that.
We know that each attribute consists of at least one letter, followed by an equals sign, which is then followed by any amount of any characters wrapped in double quotes. I am also going to allow any amount of whitespace between and section of the attributes.

Once we then apply the start-anchor to this pattern, this results in:

"^%s+%a+%s*=%s*\".-\""

Do note, we are prefixing the quotes within the pattern with a backslash in order to escape the character, this is necessary since patterns are just strings at their core.

And while we’re at it, let’s allow for any amount of whitespace between the wrapping less-than and greater-than signs, and the content of the tag, in the original pattern.

This leaves us with our final two patterns:

"<%s*/?%a+(.-)%s*>"
"^%s+%a+%s*=%s*\".-\""

Now let’s actually think about what we are doing for a second. Was this all really necessary? Couldn’t we just have settled for a simpler pattern? Well yes, we could.

Strictly speaking, anything past

"<.->"

Just isn’t necessary.

So why?
Well, just because our fancy patterns aren’t necessary, doesn’t make them useless. We can use these patterns we have created in order to validate our syntax, which will make error handling a lot easier!
In our final implementation, we will actually use all of these last 3 patterns!

Stack data structure

Now that we know how to identify tags, we need to figure out how we are going to keep track of them! While there are a couple of different ways to approach this, let’s consider what we need!

We are going to be processing text left-to-right, and we need to keep track of which tags (and with what attributes) are currently in effect. We also need to be able to easily identify which tag opened last, in order to validate closing tags.

The experienced among you may know where I am going with this, especially considering the title of this section, but we are going to be using a data structure called a Stack!

If you are not familiar with this data structure, don’t worry! It’s very simple. All you need to know is that it’s similar to a table, but you can only remove elements in a LIFO order.

For the sake of brevity (this guide is already pretty long) I will not walk you through the implementation of this data structure. Instead I will attach an implementation made by myself. If you are feeling up to the task, feel free to read through it, and consider how it works.

Stack.lua
--!strict
local Stack = {}
Stack.__index = Stack
Stack.__metatable = true

type self = {
	_data: {any}
}
export type Stack = typeof( setmetatable({} :: self, Stack) )

function Stack.new(): Stack
	local self = setmetatable({},Stack)
	self._data = {}
	return self
end

function Stack:Push(Element: any): ()
	table.insert(self._data,Element)
end

function Stack:Pop(): any
	if #self._data == 0 then error("Attempted to call Pop on an empty stack",2) end
	local Value = self._data[#self._data]
	self._data[#self._data] = nil
	return Value
end

function Stack:Peek(): any
	if #self._data == 0 then error("Attempted to call Peek on an empty stack",2) end
	return self._data[#self._data]
end

function Stack:Empty(): boolean
	return #self._data == 0
end

function Stack:Search(Element: any): number
	local Index = table.find(self._data,Element)
	if not Index then return -1 end
	return #self._data - Index + 1
end

function Stack:__tostring(): string
	return `Stack({self._data[#self._data]},#{#self._data})`
end

function Stack:__iter(): (typeof(next),{any})
	return next, self._data
end

return Stack

We are going to be using this data structure, and we will push tags onto it, whenever we encounter them, as we walk through our text.

Implementation

Now that we have assembled the tools we are going to use, we will begin writing our implementation of our text parser!

The goal here is to create a function that can separate tags from regular text content in our input string. Let’s say we want out result in the form of a table, where each entry in the table is either a tag or the text between tags.

Let’s create a boilerplate for this, as well as some types we will be using throughout the implementation. I am also going to put this in a module script, and require the Stack module.

--!strict
local RichText = {}

local Stack = require(script.Stack)

type Array<T> = {T}

type Tag = {
	Type: "TAG",
	Name: string,
	Opening: boolean,
	Content: {[string]: string},
}

type Text = {
	Type: "TEXT",
	Content: string,
}

function RichText.ParseText(Input: string): Array<Tag|Text>
	local Tokens = {}
	return Tokens
end

return RichText

Now we can begin our implementation!

First let’s consider our approach. We want to process the entire string, from left-to-right, and we want to do so in chunks, one chunk being either one tag, or the text between two tags (or until the end of the string).

We can achieve this by using a while loop, and processing the first chunk in the string, and then removing said chunk from the string. Once the input string is empty, we have processed all of it!

The first step is to check where the next tag is in the string. If this tag is at the very start of it, we will process it, otherwise, we are going to process all the text leading up until the tag we found!
This can be done using the string.find function. (This is where our patterns come in handy)

function RichText.ParseText(Input: string): {Tag|Text}
	local Tokens: {Tag|Text} = {}
	
	while #Input > 0 do
		local TagStart, TagEnd = string.find(Input, "<.->") -- Using the simple pattern to find tags.
		
		-- If there is no tag, process the rest of the string
		-- If there is a tag, and it is at the beginning, process it
		-- If there is a tag, but it is not at the beginning, process up until the tag
		local ProcessingTag = TagStart == 1
		local ChunkEnd = ProcessingTag and TagEnd or TagStart and TagStart - 1 or #Input
		
		local Chunk = string.sub(Input, 1, ChunkEnd) -- Separate the chunk
		
		if ProcessingTag then -- We are processing a tag
			table.insert(Tokens, RichText.ReadTag(Chunk) or {
				Type = "TEXT",
				Content = Chunk,
			})
		else -- We are processing text, just create a token with the content being the raw text
			table.insert(Tokens, {
				Type = "TEXT",
				Content = Chunk,
			})
		end
		
		Input = string.sub(Input, ChunkEnd + 1, -1) -- Remove the chunk we processed
	end
	
	return Tokens
end

And there we have it! That’s our ParseText function done! (with one small caveat)
You may have noticed a function called ReadTag being in there, and wondered “what is that?”. Well the answer is nothing! (yet)

The next step in our process is defining this function, which needs to take a raw string (that we know is a tag), process it, and return a table containing all the necessary information about the tag and its potential attributes. This is also where I would like to do some validation of the tag.

Now I have decided here that if a tag is invalid, I want to throw a warning in the console, and render the would-be-tag as normal text instead.

So let’s start by creating a boilerplate for this ReadTag function!
In needs to take a string as an argument, and it needs a return-type of Tag?, where if nothing is returned, we will treat that as the tag being invalid syntax.

This results in:

function RichText.ReadTag(Tag: string): Tag?
	return
end

Pretty simple, huh?
Well, let’s get to implementing! We’ll take the same approach as before, where we walk through the process mentally, to get a better idea of what we need to do!

First, let’s validate the tag using the pattern we prepared earlier. Afterwards we can strip away the first and last character, as we know these are the wrapping less-than and greater-than signs. We then need to extract the tag name, which will be split by whitespaces. The rest of the string will be any present attributes. We can work our way through the attributes using the string.gmatch function, and round up the results in a table.

Lastly we want to combine all the data we read from the string, into a properly formatted table, of the Tag type, and return it.

After a bit of fiddling around (and a lot of pattern matching) we are left with this:

function RichText.ReadTag(Tag: string): Tag?
	local ValidTag = string.match(Tag, "<%s*/?%a+(.-)%s*>")
	if not ValidTag then -- No match found means the tag is invalid
		warn(`Parsed Tag ({Tag}) could not be read.`)
		return
	end
	
	while #ValidTag > 0 do
		local Attribute = string.match(ValidTag, "^%s+%a+%s*=%s*\".-\"")
		if not Attribute then -- Tag is invalid, syntax error with attributes
			warn(`Parsed Tag ({Tag}) could not be read.`)
			return
		end
		
		ValidTag = string.sub(ValidTag, #Attribute + 1, -1)
	end

	Tag = string.sub(Tag, 2, -2) -- Strip away the first and last character.
	
	local NameChunk = string.match(Tag, "^%s*/?%a+%s*")::string -- We know this has to yield a result, otherwise the pattern above would have failed
	local Opening = string.find(NameChunk, "/") == nil -- If there is no forward slash, it is an opening tag
	local Name = string.gsub(NameChunk, "[%s/]", "") -- Removing all whitespace and forward slashes will leave only the name
	
	local AttributesChunk = string.sub(Tag, #NameChunk + 1, -1) -- The rest of the tag is all the attributes
	local Content = {} -- A dictionary containing all attributes
	
	for Attribute in string.gmatch(AttributesChunk, "%a+%s*=%s*\".-\"") do -- This loops over each attribute by matching the pattern of an attribute repeatedly through gmatch
		local Key, Value = table.unpack(string.split(Attribute, "="))
		Value = string.sub(Value, 2, -2) -- Remove the quotes around the value
		Content[Key] = Value -- Add attribute to content table
	end
	
	return { -- Build tag table and return it
		Type = "TAG",
		Name = Name,
		Opening = Opening,
		Content = Content,
	}
end

To test this, let’s run the ParseText function on some example text.

I’m going to use this input:

<test a="A" b="B">hello this is some content</test>and this is outside the tag

And it works!
image

With this, we can now parse our text, and we will be left with a table containing the tokens as we defined them.

Error handling

We could call it a day here in terms of parsing input, since we can now parse a string input, and be given the data necessary to build the UI. However I do also want to go a bit into some error handling. While we do validate tags using our pattern matching, there are a couple more points-of-error I want to cover!

Let’s create a function that validates the resulting array of tokens!
This function needs to take an array of tokens as an argument, and it should return a boolean value, that denotes whether or not the array is valid. This will allow us to add a couple of checks, and we can throw in some error messages to help us debug invalid input in the future!

Once again, let’s start by creating some boilerplate code.

function RichText.ValidateTokens(Tokens: {Tag|Text}): boolean
	return true
end

Now let’s take a second to consider what validations we want to do.
A simple on to start with, let’s validate that each opening tag has a closing tag, and let’s also ensure that all tags are nested properly.

What do I mean by proper nesting?
We want to allow nesting, as that will allow us to combine various different tags, however all nested tags must be closed in the reverse order that they were opened.
For example, this is valid nesting:

<a>
<b>
hello
</b>
</a>

However this is not:

<a>
<b>
hello
</a>
</b>

We can validate both these two aspect at once, and this is one of the places where we bring in the stack data structure!

Let’s loop over each tag, adding them to the stack if they are opening tags, and if they are closing, the must ensure that the top tag in the stack is of the same name, before we then remove it. Then at the end, we verify that the stack is empty.

function RichText.ValidateTokens(Tokens: {Tag|Text}): boolean
	local Tags = Stack.new()
	for _, Token in Tokens do
		if Token.Type ~= "TAG" then continue end -- We only care about tags for this validation
		
		if Token.Opening then
			Tags:Push(Token.Name) -- If it is an opening tag, push it to the stack
		else
			local Top = Tags:Pop() -- Remove tag from top of stack
			if Top.Name ~= Token.Name then -- Tag name mismatch
				warn(`Invalid nesting of Tags. Expected </{Top.Name}>, got </{Token.Name}>.`) -- Output expected and recieved name of tag for debugging
				return false -- Invalidate tokens
			end
		end
	end
	
	if not Tags:Empty() then -- If not all tags were closed
		for _, Tag: Tag in Tags do
			warn(`Missing closing tag </{Tag.Name}>.`) -- Output their name for debugging
		end
		return false -- Invalidate tokens
	end
	
	return true
end

These are both pretty simple validations we can run. However they yield pretty good results. There is one more validation I wish to add right now, however feel free to implement any validations you can think of.

I also want to ensure that no closing tag has any attributes.
However I don’t intend to invalidate the array of tokens if any of the closing tags does have attributes, but instead I wish to output a warning. This is done in case a user of the module mistakenly apply attributes to a closing tag, however we don’t invalidate the tokens, as they can still render perfectly fine.

We can check if the closing tag has any attributes by checking if its Content field is empty. Take note that since the Content field has string keys, we cannot check its length. Instead we use the next function.

This leaves us with this validation function (for now):

function RichText.ValidateTokens(Tokens: {Tag|Text}): boolean
	local Tags = Stack.new()
	for _, Token in Tokens do
		if Token.Type ~= "TAG" then continue end -- We only care about tags for this validation
		
		if Token.Opening then
			Tags:Push(Token.Name) -- If it is an opening tag, push it to the stack
		else
			if next(Token.Content) ~= nil then
				warn(`Warning, attributes defined for closing tag ({Token.Name}), please define attributes in opening tag instead.`)
			end
			
			local Top = Tags:Pop() -- Remove tag from top of stack
			if Top ~= Token.Name then -- Tag name mismatch
				warn(`Invalid nesting of Tags. Expected </{Top}>, got </{Token.Name}>.`) -- Output expected and recieved name of tag for debugging
				return false -- Invalidate tokens
			end
		end
	end
	
	if not Tags:Empty() then -- If not all tags were closed
		for _, Tag: Tag in Tags do
			warn(`Missing closing tag </{Tag}>.`) -- Output their name for debugging
		end
		return false -- Invalidate tokens
	end
	
	return true -- No validation issues found
end

Building UI

Creating Labels

Before we get into creating the GuiObjects needed to render our rich text, let’s first consider how we want our module to function.

I want to be able to take any text container (TextLabel, TextBox, or TextButton), pass it to the module, and our custom rich text should then be applied.

I also want to ensure that the text is the only part of the GuiObject that is affected, and that the properties of the text container still function.

Lastly, I want to be able to revert a text container that has been modified by our module, back to its original state.

To achieve this, we will create a function that takes either a TextLabel, TextBox, or TextButton as an argument, and creates all the necessary UI. And we will create a second function that reverts the actions of the first function.

Let’s once again start with some boilerplate code.

function RichText.Enrich(TextContainer: TextLabel|TextBox|TextButton): ()
	
end

function RichText.Derich(TextContainer: TextLabel|TextBox|TextButton): ()
	
end

Now in order to make our rich text reversible, let’s simply create a folder inside the text container, in which we then create all future GuiObjects. With this approach, we can reverse the effect by simply destroying this folder. I will also store information, such as the raw text of the text container, as attributes of this folder.

After we have prepared this folder, we need to call the ParseText function, while passing the text of the text container, the resulting tokens then need to be validated. Once we have validated our tokens, we can iterate over them in-order, and then create labels for the text as we go. This is again where the Stack data structure comes in handy. We can use a stack to keep track of what tags are currently active. We will update this stack as we iterate through the tokens, and each time we create a label, we can use the stack to determine which rich text effects need to be applied.

This ends up looking something a little like this:

function RichText.Enrich(TextContainer: TextContainer): ()
	local RichTextContainer = Instance.new("Folder")
	RichTextContainer.Name = "<RichTextContainer>" -- Name that is unlikely to be used by other systems
	RichTextContainer.Parent = TextContainer
	
	local Layout = Instance.new("UIListLayout")
	Layout.FillDirection = Enum.FillDirection.Horizontal
	Layout.Wraps = TextContainer.TextWrapped -- Wrap rich text if original label wrapped
	Layout.ItemLineAlignment = Enum.ItemLineAlignment.End -- Align text of different sizes at their bottom
	Layout.Parent = RichTextContainer
	
	-- Type annotation complains if the type is still TextContainer
	-- For our purposes in this function, this is fine
	local TextContainer = TextContainer::TextLabel
	
	local Text = TextContainer.Text
	
	RichTextContainer:SetAttribute("Raw", Text)
	TextContainer.Text = "" -- Clear out TextContainer text before we replace with rich text
	
	local Tokens = RichText.ParseText(Text) -- Parse the text of the TextContainer
	if not RichText.ValidateTokens(Tokens) then return end -- Validate the tokens and halt if invalid
	
	local State = Stack.new()
	for _, Token in Tokens do -- Iterate over all tokens
		if Token.Type == "TAG" then -- Tags affect the state stack
			if Token.Opening then
				State:Push(Token)
			else
				State:Pop()
			end
		else -- Text is create using labels
			BuildLabel(Token.Content, RichTextContainer, State)
		end
	end
end


function RichText.Derich(TextContainer: TextContainer): ()
	local RichTextContainer: Instance? = TextContainer:FindFirstChild("<RichTextContainer>")
	
	-- Type annotation complains if the type is still TextContainer
	-- For our purposes in this function, this is fine
	local TextContainer = TextContainer::TextLabel
	
	if RichTextContainer then
		TextContainer.Text = RichTextContainer:GetAttribute("Raw") or ""
		RichTextContainer:Destroy()
	end
end

Now once again, there’s a function here that is yet to be implemented.
We need to also implement the BuildLabel function.
This function needs to create a TextLabel, set all its properties appropriately, and then parent it to the RichTextContainer folder, at which point the label is position automatically by the UIListLayout.

Fortunately, the BuildLabel function is pretty straight forward, albeit a bit tedious.
We can start off by creating a boilerplate.

local function BuildLabel(Text: string, RichTextContainer: Folder, State: Stack.Stack): ()
	
end

Here we actually need to make a few adjustments to the Enrich function. Earlier I stated that I wanted to ensure that the properties of the text container are still functional when using the module. So we need to copy the properties that affect how text renders, so that the labels create by BuildLabel can have the correct properties.

Taking a look at the documentation for TextLabels, TextBoxs, and TextButtons, I have determined that the properties I want to carry over are:

  • FontFace
  • Font
  • LineHeight
  • TextColor3
  • TextSize
  • TextStrokeColor3
  • TextStrokeTransparency
  • TextTransparency

There are a couple more properties that should ideally be accounted for, however for simplicity, we will stick to these for now.

Let’s save all these properties using attributes, similarly to what we did with the raw text of the text container.

RichTextContainer:SetAttribute("FontFace", TextContainer.FontFace)
RichTextContainer:SetAttribute("LineHeight", TextContainer.LineHeight)
RichTextContainer:SetAttribute("TextColor3", TextContainer.TextColor3)
RichTextContainer:SetAttribute("TextSize", TextContainer.TextSize)
RichTextContainer:SetAttribute("TextStrokeColor3", TextContainer.TextStrokeColor3)
RichTextContainer:SetAttribute("TextStrokeTransparency", TextContainer.TextStrokeTransparency)
RichTextContainer:SetAttribute("TextTransparency", TextContainer.TextTransparency)

Now when we create our labels, we can set these properties to properly!

The last piece of the puzzle we need for the BuildLabel function, is a way to determine the length of the label. However this is relatively trivial! We can make use of TextService:GetTextSize.

So with that out of the way, let’s implement BuildLabel.

local function BuildLabel(Text: string, RichTextContainer: Folder, State: Stack.Stack): ()
	local Frame = Instance.new("Frame")
	Frame.BackgroundTransparency = 1

	local Label = Instance.new("TextLabel")
	Label.BackgroundTransparency = 1 -- We want to show the background of the original label instead
	Label.Text = Text
	
	local FontSize: number = RichTextContainer:GetAttribute("TextSize")
	local TextFont: Font = RichTextContainer:GetAttribute("Font")
	
	Label.FontFace = RichTextContainer:GetAttribute("FontFace")
	Label.LineHeight = RichTextContainer:GetAttribute("LineHeight")
	Label.TextColor3 = RichTextContainer:GetAttribute("TextColor3")
	Label.TextSize = FontSize
	Label.TextStrokeColor3 = RichTextContainer:GetAttribute("TextStrokeColor3")
	Label.TextStrokeTransparency = RichTextContainer:GetAttribute("TextStrokeTransparency")
	Label.TextTransparency = RichTextContainer:GetAttribute("TextTransparency")
	
	local Width = TextService:GetTextSize(Text, FontSize, TextFont, Vector2.one * math.huge).X
	
	Frame .Size = UDim2.fromOffset(Width, FontSize)

	Label.Size = UDim2.fromScale(1, 1)
	
	Label.Parent = Frame
	Frame.Parent = RichTextContainer
end

We’re almost there!

Implementing an example tag

Data structure

The last thing we need to do, is to take active tags into account. In order to do that, let’s create an example tag. For simplicity, I will create the bold tag.

First step is to figure out what a tag is (in terms of data).
For my purposes, I want tags to consist of:

  • A name, which we use as the tag
  • A function that takes a label and applies the desired effect
  • A table containing all valid attributes for the tag
  • A setting that specifies whether or not the tag needs to be applied on individual letter or not.

I will elaborate on that last one in a bit. First, let’s set up that example tag.
I’m going to create a folder inside my RichText module script, and title the folder “Tags”. I’m then gonna represent each tag by a module script inside this folder.

We can encode the name of the tag by simply naming the module script accordingly. Then we setup the module script to return a table, which contains the information we need.

return {
	Letterwise = false,
	Attributes = {},
	Apply = function(Label: TextLabel, Attributes: {[string]: string}): {RBXScriptConnection}
		return {}
	end,
}

You might be wondering why the return value of the Apply function is what it is.
I want some tags to apply effects that are updated in real time, such as color or position changes. In order to be able to clean up these tags once we call Derich, I am going to be returning all connections made by the tag, so that I can disconnect them from the RichText module.

Before we fully implement this bold tag, let’s finish up the BuildLabel function!

Wrapping up

We need to call the Apply function of all active tags, passing the label being built. However to avoid issues, we don’t want to call the Apply function of the same tag multiple times, even if there are nested tags.

A naïve way to solve this would be to only call the apply function for the most recent of each tag. However we will not do this. The reason being that if we have two active tags of the same type, but with different attributes set, we want to the Apply function once, but with all the attributes of each copy of the tag cumulated.

We can achieve this by looping over the State stack, which loops from the bottom up, and then copying the attributes to a dictionary, with the tag name as the key.

local Attributes = {}
for _, Tag in State do
	local Table = Attributes[Tag.Name] or {}
	Attributes[Tag.Name] = Table
	
	for Key, Value in Tag.Content do
		Table[Key] = Value
	end
end

However now we also need a way to access the table returned by the tag module, by the tag name. A simple approach to this is to require all the tag modules on startup, and storing them in a table with their name is the key.

Putting all this together, the BuildLabel function becomes:

local function BuildLabel(Text: string, RichTextContainer: Folder, State: Stack.Stack): ()
	local Frame = Instance.new("Frame")
	Frame.BackgroundTransparency = 1
	
	local Label = Instance.new("TextLabel")
	Label.BackgroundTransparency = 1 -- We want to show the background of the original label instead
	Label.Text = Text
	
	local FontSize: number = RichTextContainer:GetAttribute("TextSize")
	local TextFont: Font = RichTextContainer:GetAttribute("Font")
	
	Label.FontFace = RichTextContainer:GetAttribute("FontFace")
	Label.LineHeight = RichTextContainer:GetAttribute("LineHeight")
	Label.TextColor3 = RichTextContainer:GetAttribute("TextColor3")
	Label.TextSize = FontSize
	Label.TextStrokeColor3 = RichTextContainer:GetAttribute("TextStrokeColor3")
	Label.TextStrokeTransparency = RichTextContainer:GetAttribute("TextStrokeTransparency")
	Label.TextTransparency = RichTextContainer:GetAttribute("TextTransparency")
	
	local Width = TextService:GetTextSize(Text, FontSize, TextFont, Vector2.one * math.huge).X
	
	Frame.Size = UDim2.fromOffset(Width, FontSize)
	
	Label.Size = UDim2.fromScale(1, 1)
	
	Label.Parent = Frame
	Frame.Parent = RichTextContainer
	
	local Attributes = {}
	for _, Tag in State do
		local Table = Attributes[Tag.Name] or {}
		Attributes[Tag.Name] = Table
		
		for Key, Value in Tag.Content do
			Table[Key] = Value
		end
	end
	
	for Tag, Table in Attributes do
		Modules[Tag].Apply(Label, Table)
	end
end

We’re almost ready to fully implement the tag.
Let’s first store all those connections that may be returned in a table, so that we can access them once we call Derich.

At the top of the module I create a variable like so:

local Connections: {[Folder]: {RBXScriptConnection}} = {}

Then when I create the RichTextContainer folder in Enrich, I use the folder as the key in this Connections table, to point to another table.

Connections[RichTextContainer] = {}

Now back in the BuildLabel function I copy all entries in the table returned by the Apply function into this table created in the Enrich function.

for Tag, Table in Attributes do
	for _, Connection in Modules[Tag].Apply(Label, Table) do
		table.insert(Connections[RichTextContainer], Connection)
	end
end

Lastly, in the Derich function, before destroying the RichTextContainer, I disconnect all connections.

for _, Connection in Connections[RichTextContainer] do
	Connection:Disconnect()
end
table.clear(Connections[RichTextContainer])

This is good progress, but we still got two more adjustments on the agenda before we can wrap up the bold tag.

Next up I want to add another validation check, now that we have a table of valid attributes for each tag, as well as a way to check if a tag exists, I want to consider those in the ValidateTokens function.

Once those checks are added, it becomes:

function RichText.ValidateTokens(Tokens: {Tag|Text}): boolean
	local Tags = Stack.new()
	for _, Token in Tokens do
		if Token.Type ~= "TAG" then continue end -- We only care about tags for this validation
		
		if not Modules[Token.Name] then -- Tag does not exist
			warn(`Invalid token ({Token.Name}), tag does not exist.`)
			return false -- Invalidate tokens
		end
		
		for Attribute, _ in Token.Content do
			if not table.find(Modules[Token.Name].Attributes, Attribute) then -- Tag does not support attribute
				warn(`Invalid attribute ({Attribute}) defined for tag ({Token.Name}).`)
				return false -- Invalidate tokens
			end
		end
		
		if Token.Opening then
			Tags:Push(Token.Name) -- If it is an opening tag, push it to the stack
		else
			if next(Token.Content) ~= nil then
				warn(`Warning, attributes defined for closing tag ({Token.Name}), please define attributes in opening tag instead.`)
			end
			
			local Top = Tags:Pop() -- Remove tag from top of stack
			if Top ~= Token.Name then -- Tag name mismatch
				warn(`Invalid nesting of Tags. Expected </{Top}>, got </{Token.Name}>.`) -- Output expected and recieved name of tag for debugging
				return false -- Invalidate tokens
			end
		end
	end
	
	if not Tags:Empty() then -- If not all tags were closed
		for _, Tag: Tag in Tags do
			warn(`Missing closing tag </{Tag}>.`) -- Output their name for debugging
		end
		return false -- Invalidate tokens
	end
	
	return true -- No validation issues found
end

Now lastly, I want to keep track of whether or not any active tags require each letter to have their own label, and if so, create the labels accordingly.
This will be useful for certain tags like typewriter effects or text shake.

This is thankfully trivial to do, we simply keep a count of how many active tags require labels to be letterwise, and if there is at least one, we split text tokens’ content into individual letters.

local LetterwiseCount = 0
local State = Stack.new()
for _, Token in Tokens do -- Iterate over all tokens
	if Token.Type == "TAG" then -- Tags affect the state stack
		if Token.Opening then
			State:Push(Token)
			if Modules[Token.Name].Letterwise then
				LetterwiseCount += 1
			end
		else
			State:Pop()
			if Modules[Token.Name].Letterwise then
				LetterwiseCount -= 1
			end
		end
	else -- Text is create using labels
		if LetterwiseCount == 0 then
			BuildLabel(Token.Content, RichTextContainer, State)
		else
			for _, Character in string.split(Token.Content, "") do
				BuildLabel(Character, RichTextContainer, State)
			end
		end
	end
end

With this, we can go back to the bold tag module, and finish implementing it.
The easiest way for us to implement bold text in your custom rich text module is, ironically, to use Roblox’ rich text.

return {
	Letterwise = false,
	Attributes = {},
	Apply = function(Label: TextLabel, Attributes: {[string]: string}): {RBXScriptConnection}
		Label.RichText = true
		Label.Text = "<b>"..Label.Text.."</b>"
		return {}
	end,
}

And with that, we are done!
Although there are couple of changes I wish to make.
I want to set the LayoutOrder of the Labels to be the number label it is in the RichTextContainer.
This can be done like so:

Label.LayoutOrder = #RichTextContainer:GetChildren()

This does not affect anything, however it allows tags to know what index the current label is.
This can also be achieved using attributes.

While this entire system is modular, I want to hardcode one feature. I want to allow line breaks to be denoted by <br>, and I want those tags to not be included in the State stack, they should also bypass validation, as I don’t want to have to create closing tags for line breaks.

This is a relatively trivial change, however I will leave this as an exercise for you, the reader. I will give the hint that line breaks can be easily created by inserting a frame with the size scale (1, 0) into the RichTextContainer (Although this only works if your text container wraps text).

Conclusion

Well, that’s that.
Hopefully you learned something, I know I had fun creating this system as I was writing this guide.
The system is pretty powerful, well, as powerful as you make it. You have to write your own rich text tags. There are some pretty neat effects you can make though. One of my personal favorites is text shake.

I’ll leave a game file here, in case anyone is just here for the final implementation, however I highly encourage you to go through it step by step, and making any changes you see fit. It’s a great learning experience.

The game file does include a text shake effect, as an example of tags that require letterwise labels and uses connections and attributes.

RichText.rbxl (60.7 KB)

And feel free to share any cool effects you implement here, I would love to see them!

10 Likes

This reminds me of my text formatter tutorial but this one is clearly better. Good job!

Aw man, I really wish I had this tutorial 2 months ago when I started trying to rewrite defaultio’s module without a clue of how any of it worked :sweat_smile:, it’s been a rough ride.

I do have a couple questions if you don’t mind (forgive me if it’s in the post, it’s 2 am so I kinda just took a quick glance and can’t say I fully understood everything, i’ll have a proper read tomorrow):

  • Do you think you could add a preview video? I’ll be sure to check out the place file tomorrow but it’s 2am rn, i’d love to have a little preview of what your results were.
  • How would you calculate the line count and char size for the final rendered text? By the looks of it you didn’t handle multiple lines but I might have just missed it. Personally both GetTextSize and GetTextBounds gave me wrong results for scaled text, so I had to use a placeholder label and rely on it’s TextBounds by slowly substracting text from it (even this was not fully accurate, so I had to do a 2nd pass myself using the lines the previous gave me to rebalance the words across lines, and as you’ll see in the gif below the character size isn’t particularly accurate).
  • How would you handle scaling the previous to resolution and such? Line count tends to change with resolution for ScaledText and so does the distribution of the text, so i’m curious.

Sorry if any of the questions is silly, richText parsing/rendering isn’t exactly my strong point, but I needed this for a dialogue system.

Here’s my attempt and an example if anyone’s curious (i’d share the implementation but I haven’t managed to finish it, so idk if it’d be useful, and idk if you’re fine with me sharing it):

--Create marker
local Marker = SleekText.new(Frame, "<Sound = 421058925><font color=#FF7800><Sound = 154147007, Looped = true>Hi there!</Sound></font> <Animate style=wiggle>This is a dialogue 🐷!</Animate> And this is an image <Image=14280990902/>! And this is a bunch of filler text to make the thing longer.</Sound>")

--Completed
Marker.StateChanged:Connect(function(State)
	if State == "Completed" then
		print("Marker done playing")
	end
end)

--Access the marker's lines
for LineNum, Line in Marker.Lines do
	if LineNum == 1 then --Print the number of the line
		print("This is the first line.")
	end
	for _, Char in Line.Chars do --Access the line's chars
		if Char.Label:IsA("TextLabel") and Char.Label.ContentText == "H" then --Identify a specific letter
			print("This is the letter H.")
		end
	end
end

--Play
task.delay(5, function()
	Marker:Play()
end)

Hey there! Sorry about the delayed response. I finished this guide pretty late into the night, so I went to be immediately after posting it.

Before I get to your questions, I would like to clarify, this guide, while it does provide an exact implementation, is more so supposed to give insight into how I approach problem solving and system design. This is the reason why I explain my thought process throughout the guide. This point here being, some features aren’t included in the attached implementation, but could be added with relative ease.

Now, to answer your questions:
The implementation attached in the game file does support multiple lines, however only if the text container is text wrapped. For simplicity in the guide, I elected to use a UIListLayout to layout all the labels, however if I were to implement multiple lines without text wrapping, I would get rid of this UIListLayout, and position each label manually instead, I would also have to check if labels need to be split into 2 labels on new lines, however this can be done using TextService:GetTextSize. This is a bit more tedious, but would be a good solution. With this approach it is also trivial to get the line count, since you would need to keep track of the line count regardless when positioning labels.

As for the character size. The height of the label should be the FontSize. Since FontSize is really just the pixel height of a line. As for the width, I use TextService:GetTextSize, this should always work, assuming you input the correct arguments. For scaled text, your issue is likely that you aren’t passing the correct font size to this function.

As for implementing support for changes in resolution. There might be a smarter solution, but for simplicity I would just clear and reapply the rich text whenever the absolute size of the text container changes. Although with this approach it would be smart to store somewhere when the rich text was originally applied, so you can sync up effects that happen over time.

As for a preview video, fair warning: I haven’t implemented that many different tags, as this is a guide for the overall system, rather than individual effects, but here is a bit of a showcase:

I used the following input:

<b>Bold Text</b><br>
Newline!<br>
<shake>Shaking text!</shake><br>
<shake xmag="0">This only shakes vertically</shake><br>
<shake ymag="0">This only shakes horizontally</shake><br>
<shake xmag="2" ymag="2">And this shakes a lot!</shake><br>
This text is <font color="#FF0000"><b>RED</b></font><br>
<shake><font color="#1155FF">This entire line shakes and is blue!</font></shake>

Which results in this:

I also like to use typewriter and fade-in effects, however I haven’t gotten around to making those yet this time around.

I hope this helps!

1 Like