RichEditableImage - EditableImage with TextLabels

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

Made by: ChatGPT and ChatGGPT - Feedback would be appreciated! !

4 Likes

this is a crazy hack lol, drawing pixels with richtext marks is genius, how does it hold up with bigger images or updating every frame though, feels like the label count and the richtext string size would get heavy fast

2 Likes

IDK, didn’t try.

Does this bypass the roblox texture streamming if soni be using this for impacts frames and the post was recommended by roblox funny enough

1 Like

Wdym?

dose this lag tho & does it have pixelation (eg. 16x16), very nice either way

1 Like

Well, it lags when its loading, but when it’s done loading it’s perfectly fine.

Pixelation? If you mean lower quality, no, it has the same exact quality.

You do know that editables dont need the id verification now right

1 Like

Proof?

Read release notes next time KID

1 Like

Zamn, so I really did all ts for nothing :broken_heart: