Do you hate EditableImage due to the ID-verification requirement? Well look no further because we have made the solution just for you! !
Example PNG, resolution 850 x 566 with only 66 TextLabels.
The Module script (note, this requires the if-local beta feature in Studio.):
const TextService = game:GetService("TextService")
const RichEditableImage = {}
const Images = setmetatable({}, { __mode = "k" })
const Font = Enum.Font.Code
const DefaultResolution = Vector2.new(256, 256)
const DefaultTextSize = 16
const MaxTextLength = 200000
const MaxPixelsPerRowChunk = 512
const CharacterWidth = TextService:GetTextSize(
" ",
DefaultTextSize,
Font,
Vector2.new(100, 100)
).X
const function GetOffset(Width: number, X: number, Y: number): number
return (Y * Width + X) * 4
end
const function GetTag(R: number, G: number, B: number, Alpha: number): string
local Color = ""
if R ~= 255 or G ~= 255 or B ~= 255 then
const Hex = Color3.fromRGB(R, G, B):ToHex():gsub("^(%x)%1(%x)%2(%x)%3$", "%1%2%3")
Color = ` color="#{Hex}"`
end
if Alpha == 255 then
return `<mark{Color}>`
else
const Transparency = string.format("%.3f", 1 - Alpha / 255):gsub("^0?(%.%d-)0*$", "%1")
return `<mark{Color} transparency="{Transparency}">`
end
end
const function EncodeRow(
Pixels: buffer,
Width: number,
Y: number,
StartX: number,
EndX: number,
Tags: { [number]: string }
): string
const Parts = {}
local X = StartX
const RowOffset = Y * Width * 4
while X < EndX do
const Offset = RowOffset + X * 4
const Pixel = buffer.readu32(Pixels, Offset)
const Alpha = buffer.readu8(Pixels, Offset + 3)
local RunEnd = X + 1
while RunEnd < EndX do
const NextOffset = RowOffset + RunEnd * 4
if Alpha == 0 then
if buffer.readu8(Pixels, NextOffset + 3) ~= 0 then
break
end
elseif buffer.readu32(Pixels, NextOffset) ~= Pixel then
break
end
RunEnd += 1
end
const Spaces = string.rep(" ", RunEnd - X)
if Alpha == 0 then
table.insert(Parts, Spaces)
else
if const Tag = Tags[Pixel] then
table.insert(Parts, Tag .. Spaces .. "</mark>")
else
const NewTag = GetTag(
buffer.readu8(Pixels, Offset),
buffer.readu8(Pixels, Offset + 1),
buffer.readu8(Pixels, Offset + 2),
Alpha
)
Tags[Pixel] = NewTag
table.insert(Parts, NewTag .. Spaces .. "</mark>")
end
end
X = RunEnd
end
return table.concat(Parts)
end
const function Render(Image: CanvasGroup)
if const State = Images[Image] then
const Resolution = State.Resolution
const Width, Height = Resolution.X, Resolution.Y
const DisplaySize = Image.AbsoluteSize
if DisplaySize.X <= 0 or DisplaySize.Y <= 0 then
return
end
if State.RenderedRevision == State.Revision
and State.RenderedResolution == Resolution
and State.RenderedDisplaySize == DisplaySize then
return
end
Image:ClearAllChildren()
const CellWidth = DisplaySize.X / Width
const CellHeight = DisplaySize.Y / Height
const TextSize = math.max(1, CellWidth * DefaultTextSize / CharacterWidth)
const LineHeight = CellHeight / TextSize
const Tags = {}
for StartX = 0, Width - 1, MaxPixelsPerRowChunk do
const EndX = math.min(StartX + MaxPixelsPerRowChunk, Width)
const ChunkWidth = EndX - StartX
const Rows = {}
local CurrentLength = 0
local FirstRow = 0
const function Flush(EndY: number)
if #Rows == 0 then
return
end
const Label = Instance.new("TextLabel")
Label.Name = "PixelText"
Label.BackgroundTransparency = 1
Label.Font = Font
Label.TextSize = TextSize
Label.LineHeight = LineHeight
Label.RichText = true
Label.Position = UDim2.fromOffset(StartX * CellWidth, FirstRow * CellHeight)
Label.Size = UDim2.fromOffset(
ChunkWidth * CellWidth,
(EndY - FirstRow) * CellHeight
)
Label.ZIndex = Image.ZIndex
Label.Text = table.concat(Rows)
Label.Parent = Image
table.clear(Rows)
CurrentLength = 0
FirstRow = EndY
end
for Y = 0, Height - 1 do
const RowText = EncodeRow(
State.Pixels,
Width,
Y,
StartX,
EndX,
Tags
) .. "\n"
if #Rows > 0 and CurrentLength + #RowText >= MaxTextLength then
Flush(Y)
end
table.insert(Rows, RowText)
CurrentLength += #RowText
end
Flush(Height)
end
State.RenderedRevision = State.Revision
State.RenderedResolution = Resolution
State.RenderedDisplaySize = DisplaySize
end
end
const function ScheduleRender(Image: CanvasGroup)
if const State = Images[Image] then
if State.RenderQueued then
return
end
State.RenderQueued = true
task.defer(function()
if const CurrentState = Images[Image] then
CurrentState.RenderQueued = false
Render(Image)
end
end)
end
end
function RichEditableImage.new(): CanvasGroup
const Image = Instance.new("CanvasGroup")
Image.Name = "RichEditableImage"
Image.BackgroundTransparency = 1
const State = {
Resolution = DefaultResolution,
Pixels = buffer.create(DefaultResolution.X * DefaultResolution.Y * 4),
Connections = {},
Revision = 0,
RenderedRevision = -1,
RenderedResolution = nil,
RenderedDisplaySize = nil,
RenderQueued = false,
}
Images[Image] = State
table.insert(State.Connections, Image:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
ScheduleRender(Image)
end))
table.insert(State.Connections, Image:GetPropertyChangedSignal("ZIndex"):Connect(function()
for _, Label in Image:QueryDescendants(">TextLabel#PixelText") do
Label.ZIndex = Image.ZIndex
end
end))
table.insert(State.Connections, Image.Destroying:Connect(function()
for _, Connection in State.Connections do
Connection:Disconnect()
end
Images[Image] = nil
end))
return Image
end
function RichEditableImage.SetResolution(Image: CanvasGroup, Resolution: Vector2)
const State = assert(Images[Image], "Invalid RichEditableImage")
const Width, Height = math.floor(Resolution.X), math.floor(Resolution.Y)
assert(Width > 0 and Height > 0, "Resolution must be positive")
const OldResolution = State.Resolution
if Width == OldResolution.X and Height == OldResolution.Y then
return
end
const Pixels = buffer.create(Width * Height * 4)
const CopyWidth = math.min(OldResolution.X, Width) * 4
const CopyHeight = math.min(OldResolution.Y, Height)
for Y = 0, CopyHeight - 1 do
buffer.copy(
Pixels,
Y * Width * 4,
State.Pixels,
Y * OldResolution.X * 4,
CopyWidth
)
end
State.Resolution = Vector2.new(Width, Height)
State.Pixels = Pixels
State.Revision += 1
ScheduleRender(Image)
end
function RichEditableImage.WritePixelsBuffer(
Image: CanvasGroup,
Position: Vector2,
Size: Vector2,
Pixels: buffer
)
const State = assert(Images[Image], "Invalid RichEditableImage")
const Resolution = State.Resolution
const Width, Height = math.floor(Size.X), math.floor(Size.Y)
const X, Y = math.floor(Position.X), math.floor(Position.Y)
assert(Width >= 0 and Height >= 0, "Invalid write size")
assert(buffer.len(Pixels) >= Width * Height * 4, "Pixel buffer is too small")
const X0, X1 = math.max(0, -X), math.min(Width, Resolution.X - X)
const Y0, Y1 = math.max(0, -Y), math.min(Height, Resolution.Y)
if X0 >= X1 or Y0 >= Y1 then
return
end
for Row = Y0, Y1 - 1 do
buffer.copy(
State.Pixels,
GetOffset(Resolution.X, X + X0, Y + Row),
Pixels,
(Row * Width + X0) * 4,
(X1 - X0) * 4
)
end
State.Revision += 1
ScheduleRender(Image)
end
function RichEditableImage.ReadPixelsBuffer(
Image: CanvasGroup,
Position: Vector2,
Size: Vector2
): buffer
const State = assert(Images[Image], "Invalid RichEditableImage")
const Resolution = State.Resolution
const Width, Height = math.floor(Size.X), math.floor(Size.Y)
const X, Y = math.floor(Position.X), math.floor(Position.Y)
assert(Width >= 0 and Height >= 0, "Invalid read size")
const Result = buffer.create(Width * Height * 4)
const X0, X1 = math.max(0, -X), math.min(Width, Resolution.X - X)
const Y0, Y1 = math.max(0, -Y), math.min(Height, Resolution.Y - Y)
for Row = Y0, Y1 - 1 do
buffer.copy(
Result,
(Row * Width + X0) * 4,
State.Pixels,
GetOffset(Resolution.X, X + X0, Y + Row),
(X1 - X0) * 4
)
end
return Result
end
function RichEditableImage.Clear(Image: CanvasGroup)
const State = assert(Images[Image], "Invalid RichEditableImage")
buffer.fill(State.Pixels, 0, 0)
State.Revision += 1
ScheduleRender(Image)
end
return RichEditableImage

