Text Formatter in Roblox

So without further ado, let me begin.

Backbone of the module.

The module is quite simple, it just takes in a text and splits it into individual words and processes the words into a table which are then used to format everything!

The splitting.

I use string.split to split the text into it’s individual letters. Now, there are two ways to go by this out of which one way does not work.

Most people would just loop through the characters like so:

for index = 1,#text do
	local letter = text:sub(index,index)
end

This won’t work as I am going to be skipping letters a lot and using a simple continue would just complicate everything! Therefore, I use while loops. Like so:

while index < #text do
	index += 1
	local letter = text:sub(index,index)
end

This is good because I can skip to a certain index.

Basic markdown.

Now that I know how the loop works, I can get on with the markdown. For this, I need a state table like so:

local states = {
	BoldActive = false,
	ItalicActive = false,
	UnderlineActive = false,
	StrikeActive = false,
	PerformattedActive = false
}

What this table essentially does is it allows us to know if the text is going to be bold,italic or the other listed things.

So, how do I actually set these values? I will use if-statements of course!

Just like so:

if text:sub(index,index+1) == "**" then
	states.BoldActive = not states.BoldActive
	index += 1
	continue
end

Okay so that already looks pretty tricky correct? Let me break it down for you smooth brains- Ahem! Apologies.
So basically I would first see that if our letter and the next letter is equal to double asterisk. This basically identifies the bold state and so I will set the bold active state to be the opposite of it’s current state, this ensures that I can close the bold state later on.
This can be repeated for all other states like so:

if letter..nextLetter == "**" then
	states.Bold = not states.Bold
	idx += 1
	continue
elseif letter == "*" then
	states.Italic = not states.Italic
	continue
elseif letter == "_" then
	states.Underline = not states.Underline
	continue
elseif letter == "~" then
	states.Strike = not states.Strike
	continue
elseif letter == "`" then
	states.Performatted = not states.Performatted
	continue
end

In my case, I used nextLetter to represent text:sub(index+1,index+1).
You see, I used index + 1 or idx + 1 because now I can skip to the next ACTUAL letter or another markdown.

Storing the data.

This is the easiest part as we just do:

letters[idx] = data

Where data is:

local data = {
	Letter = letter,
	Bolded = states.Bold,
	Italicized = states.Italic,
	Underlines = states.Underline,
	StrikedThrough = states.Strike,
	Performatted = states.Performatted,
}

As you can see, we are using the states that we made earlier here.

Applying the markdown on the text.

This would again use a lot of if statements. But before that we will loop through the letters table as we talked about in the previous section.
So just use a for loop for this like so:

for idx,letterData in letters do
	local letterText = letterData.Letter
	if letterData.Bolded then
		letterText = "<b>"..letterText.."</b>"
	end
	if letterData.Italicized then
		letterText = "<i>"..letterText.."</i>"
	end
	if letterData.Underlined then
		letterText = "<u>"..letterText.."</u>"
	end
	if letterData.StrikedThrough then
		letterText = "<s>"..letterText.."</s>"
	end
	local letter = MakeLetter(Label,letterText,idx)
	if letterData.Performatted then
		letter.FontFace = Font.new("rbxassetid://12187362578")
	end
end

You may wonder that why can’t I just use one statement with elseifs? Well, that’s because it would not allow us to use mixing and matching! If I just use one if-statement with multiple elseifs it won’t give us the ability to mix and match like so:
***bold+italic*** would be bold+italic. (If we used elseifs.)
But with separate if-statements we get:
bold+italic!
Just how we want.

Finally, you wonder what the function MakeLetter is, correct? I would say it requires a whole another section for it but to keep it simple, it simply makes a text label with all the properties of our parent text label with the addition of being scaled and sized properly.

Finalizing and parenting the letters.

Now that we have letters as text labels, we need to know how we can parent them and make them appear properly! For this purpose, we are going to use UiListLayout with their new Flex Features!
So for this, just make a UIListLayout like so:

local listLayout = Instance.new("UIListLayout")
listLayout.Wraps = true
listLayout.FillDirection = Enum.FillDirection.Horizontal
listLayout.Parent = Label
listLayout.Name = "TextList"
listLayout.SortOrder = Enum.SortOrder.LayoutOrder

Ensure that its fill direction is kept Horizontal.
And that’s all. We have a rough concept idea of how a text formatted would actually work.

That’s all, I hope you learned something new. Till next time!

  • Great tutorial!! You should make more.
  • Bad tutorial but make more tutorials.
  • Bad tutorial, don’t make more tutorials.

0 voters

6 Likes

please make a tutortial about camera manipualtion pleaseee pleaseeeee PLEASEEE

i will love you so much if you make one

1 Like

Woah neat! Im totally going to have to yoink this and put it into my custom ui types thingy. Thanks for the tutorial!

possibly lol

EXACTLY!! Ive had dreams about this, its the whole reason I started lucid dreaming, so I could be spiderman :sob:

2 Likes

does it work? should i try it???

1 Like

If you want too, go for it!

I personally found it kinda easy ig. Its fun though lol

1 Like

whats it like

1 Like

Were getting off-topic so I can tell you in one of the gcs.

2 Likes

Checking for every letter is bad, it’s much better to use string.gsub() in your case. It won’t be that easy though, cuz you will need to store matches separately, but it will help with solving most of the problems with repeated tags like *****what will be*** there****?

1 Like

That’s not helping. If you have a logical answer that works 1:1 like how my way does then let me know.

It’s also worth noting that this module is made for stuff like chat systems which do not use dynamic updating.

Also FYI, I have already thought of using string.gsub but it simply isn’t that good.

1 Like

wow… new year new awry!

11th of January 2025.

@awry_y still wiating on that camera manipualtion tutorial :^)))

2 Likes

I did my chat system this way:


local function NormalizeText(Text)
	local OpenedTags = {
		ib = false,
		b = false,
		i = false,
		u = false,
		s = false
	}
	local ExistingFonts = {
		"AmaticSC",
		"RomanAntique",
		"PressStart2P",
		"Arial",
		"Arimo",
		"Bangers",
		"AccanthisADFStd",
		"BuilderSans",
		"ComicNeueAngular",
		"Inconsolata",
		"Creepster",
		"DenkOne",
		"Balthazar",
		"Fondamento",
		"FredokaOne",
		"Guru",
		"GothamSSm",
		"GrenzeGotisch",
		"HighwayGothic",
		"IndieFlower",
		"JosefinSans",
		"Jura",
		"Kalam",
		"LegacyArial",
		"LuckiestGuy",
		"Merriweather",
		"Michroma",
		"Nunito",
		"Oswald",
		"PatrickHand",
		"PermanentMarker",
		"Roboto",
		"RobotoCondensed",
		"RobotoMono",
		"Sarpanch",
		"Zekton",
		"SourceSansPro",
		"SpecialElite",
		"TitilliumWeb",
		"Ubuntu",
	}
	local AdditionalBrickColor = {
		Red = Color3.fromRGB(255, 0, 0),
		Orange = Color3.fromRGB(255, 127, 0),
		Yellow = Color3.fromRGB(255, 255, 0),
		Lime = Color3.fromRGB(140, 255, 0),
		Green = Color3.fromRGB(0, 255, 0),
		Teal = Color3.fromRGB(0, 255, 127),
		Cyan = Color3.fromRGB(0, 255, 255),
		Blue = Color3.fromRGB(0, 63, 255),
		Purple = Color3.fromRGB(127, 0, 255),
		Magenta = Color3.fromRGB(255, 0, 255),
		Pink = Color3.fromRGB(255, 127, 196),
		Black = Color3.fromRGB(0, 0, 0),
		White = Color3.fromRGB(255, 255, 255),
		Grey = Color3.fromRGB(127, 127, 127),
	}
	local RichTagsPositions = {}
	local Cursor = 0
	local Result = Text
	local RichTextTransforms = {
		[1] = {
			Match = "(%*%*%*)(.?)",
			Transform = function(Offset, Stars, After)
				if string.sub(Offset, #Offset, #Offset) == "*" or string.sub(After, 1, 1) == "*" then
					return Offset .. After
				else
					table.insert(RichTagsPositions, {
						Pos = string.len(Offset),
						Tag = OpenedTags.ib and "</i></b>" or "<b><i>",
						Diff = OpenedTags.ib and 5 or 3,
					})
					OpenedTags.ib = not OpenedTags.ib
					return Offset .. After
				end
			end,
			Untransform = function()
				if OpenedTags.ib then
					local Last = RichTagsPositions[#RichTagsPositions]
					Result = string.sub(Result, 1, Last.Pos) .. "***" .. string.sub(Result, Last.Pos+1, -1)
					table.remove(RichTagsPositions, #RichTagsPositions)
				end
			end,
		},
		[2] = {
			Match = "(%*%*)(.?)",
			Transform = function(Offset, Stars, After)
				if string.sub(Offset, #Offset, #Offset) == "*" or string.sub(After, 1, 1) == "*" then
					return Offset .. After
				else
					table.insert(RichTagsPositions, {
						Pos = string.len(Offset),
						Tag = OpenedTags.b and "</b>" or "<b>",
						Diff = OpenedTags.b and 2 or 1,
					})
					OpenedTags.b = not OpenedTags.b
					return Offset .. After
				end
			end,
			Untransform = function()
				if OpenedTags.b then
					local Last = RichTagsPositions[#RichTagsPositions]
					Result = string.sub(Result, 1, Last.Pos) .. "**" .. string.sub(Result, Last.Pos+1, -1)
					table.remove(RichTagsPositions, #RichTagsPositions)
				end
			end,
		},
		[3] = {
			Match = "(%*)(.?)",
			Transform = function(Offset, Stars, After)
				if string.sub(Offset, #Offset, #Offset) == "*" or string.sub(After, 1, 1) == "*" then
					return Offset .. After
				else
					table.insert(RichTagsPositions, {
						Pos = string.len(Offset),
						Tag = OpenedTags.i and "</i>" or "<i>",
						Diff = OpenedTags.i and 3 or 2,
					})
					OpenedTags.i = not OpenedTags.i
					return Offset .. After
				end
			end,
			Untransform = function()
				if OpenedTags.i then
					local Last = RichTagsPositions[#RichTagsPositions]
					Result = string.sub(Result, 1, Last.Pos) .. "*" .. string.sub(Result, Last.Pos+1, -1)
					table.remove(RichTagsPositions, #RichTagsPositions)
				end
			end,
		},
		[4] = {
			Match = "(__)(.?)",
			Transform = function(Offset, Stars, After)
				if string.sub(Offset, #Offset, #Offset) == "_" or string.sub(After, 1, 1) == "_" then
					return Offset .. After
				else
					table.insert(RichTagsPositions, {
						Pos = string.len(Offset),
						Tag = OpenedTags.u and "</u>" or "<u>",
						Diff = OpenedTags.u and 2 or 1,
					})
					OpenedTags.u = not OpenedTags.u
					return Offset .. After
				end
			end,
			Untransform = function()
				if OpenedTags.u then
					local Last = RichTagsPositions[#RichTagsPositions]
					Result = string.sub(Result, 1, Last.Pos) .. "__" .. string.sub(Result, Last.Pos+1, -1)
					table.remove(RichTagsPositions, #RichTagsPositions)
				end
			end,
		},
		[5] = {
			Match = "(~~)(.?)",
			Transform = function(Offset, Stars, After)
				if string.sub(Offset, #Offset, #Offset) == "~" or string.sub(After, 1, 1) == "~" then
					return Offset .. After
				else
					table.insert(RichTagsPositions, {
						Pos = string.len(Offset),
						Tag = OpenedTags.s and "</s>" or "<s>",
						Diff = OpenedTags.s and 2 or 1,
					})
					OpenedTags.s = not OpenedTags.s
					return Offset .. After
				end
			end,
			Untransform = function()
				if OpenedTags.s then
					local Last = RichTagsPositions[#RichTagsPositions]
					Result = string.sub(Result, 1, Last.Pos) .. "~~" .. string.sub(Result, Last.Pos+1, -1)
					table.remove(RichTagsPositions, #RichTagsPositions)
				end
			end,
		},
		[6] = {
			Match = "(%b<>)",
			Transform = function(Offset, Input)
				local Total, Keyword = string.match(Input, "<((%a+).*)>")
				if Keyword == "color" then
					local ColorFormats = {
						["color (%d+) (%d+) (%d+) (.*)"] = function(R, G, B, Input)
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset), 
								Tag = string.format("<font color=\"rgb(%d,%d,%d)\">", R, G, B),
								Diff = 11,
							})
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset) + string.len(Input),
								Tag = string.format("</font>"),
								Diff = 7,
							})
							return Offset .. Input
						end,
						["color #(%x%x%x) (.*)"] = function(Hex, Input)
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset),
								Tag = string.format("<font color=\"#%s\">", Hex),
								Diff = 6,
							})
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset) + string.len(Input),
								Tag = string.format("</font>"),
								Diff = 7,
							})
							return Offset .. Input
						end,
						["color #(%x%x%x%x%x%x) (.*)"] = function(Hex, Input)
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset),
								Tag = string.format("<font color=\"#%s\">", Hex),
								Diff = 6,
							})
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset) + string.len(Input),
								Tag = string.format("</font>"),
								Diff = 7,
							})
							return Offset .. Input
						end,
						["color \"([^<>]-)\" (.*)"] = function(BrickColorName, Input)
							local Color = AdditionalBrickColor[BrickColorName] or BrickColor.new(BrickColorName).Color
							local R, G, B = math.floor(Color.R*255), math.floor(Color.G*255), math.floor(Color.B*255)
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset),
								Tag = string.format("<font color=\"rgb(%d,%d,%d)\">", R, G, B),
								Diff = 11 - string.len(BrickColorName) + string.len(tostring(R*1000000+G*1000+B))-2,				
							})
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset) + string.len(Input),
								Tag = string.format("</font>"),
								Diff = 7,
							})
							return Offset .. Input
						end,
					}
					for Pattern, Function in pairs(ColorFormats) do
						if string.match(Total, Pattern) then
							return Function(string.match(Total, Pattern))
						end
					end
					return Offset .. "[" .. Total .. "]"
				elseif Keyword == "stroke" then
					local ColorFormats = {
						["stroke (%d+) (%d+) (%d+) (.*)"] = function(R, G, B, Input)
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset), 
								Tag = string.format("<stroke color=\"rgb(%d,%d,%d)\">", R, G, B),
								Diff = 12,
							})
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset) + string.len(Input),
								Tag = string.format("</stroke>"),
								Diff = 9,
							})
							return Offset .. Input
						end,
						["stroke #(%x%x%x) (.*)"] = function(Hex, Input)
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset),
								Tag = string.format("<stroke color=\"#%s\">", Hex),
								Diff = 7,
							})
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset) + string.len(Input),
								Tag = string.format("</stroke>"),
								Diff = 9,
							})
							return Offset .. Input
						end,
						["stroke #(%x%x%x%x%x%x) (.*)"] = function(Hex, Input)
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset),
								Tag = string.format("<stroke color=\"#%s\">", Hex),
								Diff = 7,
							})
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset) + string.len(Input),
								Tag = string.format("</stroke>"),
								Diff = 9,
							})
							return Offset .. Input
						end,
						["stroke \"([^<>]-)\" (.*)"] = function(BrickColorName, Input)
							local Color = AdditionalBrickColor[BrickColorName] or BrickColor.new(BrickColorName).Color
							local R, G, B = math.floor(Color.R*255), math.floor(Color.G*255), math.floor(Color.B*255)
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset),
								Tag = string.format("<stroke color=\"rgb(%d,%d,%d)\">", R, G, B),
								Diff = 12 - string.len(BrickColorName) + string.len(R*1000000+G*1000+B)-2,				
							})
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset) + string.len(Input),
								Tag = string.format("</stroke>"),
								Diff = 9,
							})
							return Offset .. Input
						end,
					}
					for Pattern, Function in pairs(ColorFormats) do
						if string.match(Total, Pattern) then
							return Function(string.match(Total, Pattern))
						end
					end
					return Offset .. "[" .. Total .. "]"
				elseif Keyword == "font" then
					--warn("Font keyword is being used!")
					--warn(Other)
					--warn(string.match(Other, "(%a+) ([^<>]*)"))
					if string.match(Total, "font (%a+) (.*)") then
						local FontName, Input = string.match(Total, "font (%a+) (.*)")
						--warn("|", FontName, "|", Input, "|")
						if table.find(ExistingFonts, FontName) then
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset),
								Tag = string.format("<font face=\"%s\">", FontName),
								Diff = 7,
							})
							table.insert(RichTagsPositions, {
								Pos = string.len(Offset) + string.len(Input),
								Tag = string.format("</font>"),
								Diff = 7,
							})
						end
						return Offset .. Input
					end
				end
				return Offset .. Total
			end,
		},

	for i = 1, #RichTextTransforms, 1 do
		local Match = "^(.-)"-- .. RichTextTransforms[i].Match
		local Count = 0
		repeat
			Result, Count = string.gsub(Result, Match..RichTextTransforms[i].Match, RichTextTransforms[i].Transform)
		until Count == 0
		if RichTextTransforms[i].Untransform then
			RichTextTransforms[i].Untransform()
		end
	end

	return Result, RichTagsPositions
end

Later, RichTagsPositions are used to build string back. How it is done is separate hellish math but it works good.
image

1 Like

I want to use this but my smooth brain does not know how to use this. TOOOO MANY SYMBOLS.

Good tutorial, I will now spend 3 hours smashing my brain to learn how to use this :smiley::smiley::smiley::smiley:

1 Like

just a heads up, that table is not needed because you can just do

if Enum.Font[fontName] then

and if you want the items

local existingFonts = Enum.Font:GetEnumItems()
2 Likes

Your implementation seems more worse than mine.

You are using unique tags for all combinations which is inefficient. Also worth noting that you are using more lines of code as compared to mine. Which does prove that my method is more convenient and overall better. I do know that more lines does not equal more better code but as this is sort of a competition, my code wins in simplicity, less likes of code, more features while your wins in less amount of instances used.

1 Like

Don’t do that… also congrats on finding the easter egg! You are clearly not a smooth brain!

1 Like

Most of the code I use is to make proper support for TextChatService, because of it’s limit of 200 metadata symbols, and ontop it will tag entire message if I just change them to this: <font color="rgb(255 0 0)">Red</font>. At least because there’s 3 numbers which will result in someone’s card pin code.
It translated into metadata string like this one:
POSITION TAG + for color cRRGGBB or for Hex color hRRGGBB + for font FONTID + for stroke it’s the same as color but with capital letters. For bold, italic and such, it uses BIUSD (D = both bold and italic). Everything encoded into base64
This results in string like AFc00FF00 B7HFFFF00 FDD FFU, which helps alot. But that’s my particular use case.

1 Like