nupp.text.utf8

nupp.text.utf8 reads Unicode scalars out of strings and nupp.io.ByteView values. Reach for it to count, decode, validate or truncate text whose bytes came from somewhere else.

const utf8 = nupp.text.utf8

assert(utf8.length("A€") == 2)
assert(utf8.encode(0x20ac) == "€")

length counts scalars, replacing each malformed byte with one replacement scalar rather than refusing the string. encode goes the other way, from one scalar value to its bytes.

Byte offsets#

Offsets here are 1-based, so they compose with Lua string positions and with string.sub on the same text. This is the opposite of the zero-based offsets a buffer uses, because those are offsets into storage rather than positions in text.

Decoding a scalar#

decodeAt decodes forward from an offset and answers the scalar with the offset after it. decodeBefore decodes the scalar ending before an offset and answers it with the offset it starts at, so the two walk in opposite directions over the same positions:

const utf8 = nupp.text.utf8

local codepoint, nextByte = utf8.decodeAt("A€", 2)
assert(codepoint == 0x20ac and nextByte == 5)

codepoint, nextByte = utf8.decodeBefore("A€", nextByte)
assert(codepoint == 0x20ac and nextByte == 2)

A malformed byte answers 0xFFFD and advances exactly one byte, so a walk over damaged text makes progress rather than stalling. Nil is the end of the value, and that is what stops the walk:

const utf8 = nupp.text.utf8

local at = 1
while true do
    local codepoint, nextAt = utf8.decodeAt(text, at)
    if codepoint == nil then break end
    at = nextAt
end

One past the end is an accepted offset; anything further raises.

Validating and truncating#

Validation is explicit, because decoding never refuses. isValid checks a complete byte sequence:

const utf8 = nupp.text.utf8

assert(utf8.isValid("café"))
assert(not utf8.isValid("\xff"))

Overlong forms, surrogate halves and codepoints above the maximum are malformed even though their lead bytes are well formed, so they are rejected on the value rather than on the shape.

validPrefixLength(value, maxBytes) answers the length of the largest valid prefix no longer than a byte budget, which is what keeps a fixed-width field from splitting a scalar in half. truncate applies that to a string and copies the prefix:

const utf8 = nupp.text.utf8

assert(utf8.validPrefixLength("A€B", 4) == 4)
assert(utf8.truncate("A€B", 4) == "A€")

truncate takes a string, since it answers one. Everything else here takes a string or a byte view.

Module contents

Functions

FunctionKindDescription
decodeAtfunctionReads the codepoint starting at a byte offset, and where the next one starts.
decodeBeforefunctionReads the codepoint ending just before a byte offset, and where it starts.
encodefunctionEncodes one codepoint as UTF-8 bytes.
isValidfunctionReports whether the whole value is well-formed UTF-8.
lengthfunctionCounts the codepoints in the value.
truncatefunctionCuts text to at most maxBytes, never through a codepoint.
validPrefixLengthfunctionMeasures the longest well-formed prefix no longer than maxBytes.

Functions#

utf8.decodeAtfunction#

function utf8.decodeAt(value: string | ByteView, byteOffset: integer): integer?, integer

Reads the codepoint starting at a byte offset, and where the next one starts.

Walking a buffer is this call and nothing else, because a malformed byte answers 0xFFFD and still advances:

const utf8 = nupp.text.utf8

local at = 1
while true do
    local codepoint, nextAt = utf8.decodeAt(text, at)
    if codepoint == nil then break end
    at = nextAt
end

Arguments

NameTypeDescription
valuestring | ByteView

the bytes to read

byteOffsetinteger

the 1-based byte to start at; one past the end is allowed

Returns

TypeDescription
integer?

the codepoint, or nil at the end of the value

integer

the byte the next codepoint starts at

Raises

  • when byteOffset is not an integer, or is outside the value

utf8.decodeBeforefunction#

function utf8.decodeBefore(value: string | ByteView, byteOffset: integer): integer?, integer

Reads the codepoint ending just before a byte offset, and where it starts.

decodeAt and decodeBefore walk the same positions in opposite directions, so decoding forward and then back lands where it started:

const utf8 = nupp.text.utf8

local codepoint, nextByte = utf8.decodeAt("A€", 2)
assert(codepoint == 0x20ac and nextByte == 5)

codepoint, nextByte = utf8.decodeBefore("A€", nextByte)
assert(codepoint == 0x20ac and nextByte == 2)

Arguments

NameTypeDescription
valuestring | ByteView

the bytes to read

byteOffsetinteger

the 1-based byte to look back from

Returns

TypeDescription
integer?

the codepoint, or nil at the start of the value

integer

the byte that codepoint starts at

Raises

  • when byteOffset is not an integer, or is outside the value

utf8.encodefunction#

function utf8.encode(codepoint: integer): string

Encodes one codepoint as UTF-8 bytes.

const utf8 = nupp.text.utf8

assert(utf8.encode(0x20ac) == "€")

Surrogate halves encode rather than raise, the way a lone half decodes to the replacement rather than stopping a walk: what refuses them is nupp.text.utf8.isValid, on the bytes, and encoding one is how a caller reproducing damaged text writes it back out.

Arguments

NameTypeDescription
codepointinteger

the codepoint to encode

Returns

TypeDescription
string

its encoding

Raises

  • when the codepoint is not an integer, or is outside Unicode

utf8.isValidfunction#

function utf8.isValid(value: string | ByteView): boolean

Reports whether the whole value is well-formed UTF-8.

const utf8 = nupp.text.utf8

assert(utf8.isValid("café"))
assert(not utf8.isValid("\xff"))

Arguments

NameTypeDescription
valuestring | ByteView

the bytes to test

Returns

TypeDescription
boolean

whether every sequence is well formed

utf8.lengthfunction#

function utf8.length(value: string | ByteView): integer

Counts the codepoints in the value.

A malformed byte counts as one.

const utf8 = nupp.text.utf8

assert(utf8.length("A€") == 2)

Arguments

NameTypeDescription
valuestring | ByteView

the bytes to measure

Returns

TypeDescription
integer

the codepoint count

utf8.truncatefunction#

function utf8.truncate(text: string, maxBytes: integer): string

Cuts text to at most maxBytes, never through a codepoint.

const utf8 = nupp.text.utf8

assert(utf8.truncate("A€B", 4) == "A€")

Arguments

NameTypeDescription
textstring

the text to cut

maxBytesinteger

the most bytes the answer may use

Returns

TypeDescription
string

the cut text

utf8.validPrefixLengthfunction#

function utf8.validPrefixLength(value: string | ByteView, maxBytes: integer): integer

Measures the longest well-formed prefix no longer than maxBytes.

const utf8 = nupp.text.utf8

-- "A€B" is 5 bytes; the 4-byte budget stops just short of "B" rather
-- than splitting "€" in half.
assert(utf8.validPrefixLength("A€B", 4) == 4)

Arguments

NameTypeDescription
valuestring | ByteView

the bytes to measure

maxBytesinteger

the most bytes the prefix may use

Returns

TypeDescription
integer

the prefix length, in bytes