Bytes, str and Unicode
A str is a sequence of Unicode code points. A bytes object is a sequence of numbers from 0 to 255. Files, networks and hashes work in bytes, so text is encoded on the way out and decoded on the way in.
s = "café"
b = s.encode("utf-8")
print(len(s), len(b)) # 4 5
print(b) # b'caf\xc3\xa9'
print(b[0], list(b[3:]))
print(b.decode("utf-8"), ord("é"))UTF-8 uses one byte for ASCII and up to four for other characters, so the length in characters and the length in bytes differ. Decoding with the wrong codec raises UnicodeDecodeError or, worse, quietly produces the wrong text. errors="replace" puts the marker U+FFFD in place of bad bytes.
Equal-looking text
import unicodedata
a = "café"
b = "café"
print(a == b, len(a), len(b))
nfc = unicodedata.normalize("NFC", b)
print(a == nfc)The same letter can be one code point or a letter plus a combining accent. normalize("NFC", s) picks the composed form. For case-insensitive comparison use s.casefold(), a stronger lower() that also turns ß into ss.
Your task
Write utf8_size(text) returning how many bytes text takes in UTF-8. Write safe_decode(data) decoding UTF-8 bytes with each invalid byte replaced by U+FFFD instead of raising. Write same_text(a, b) telling whether two strings match after NFC normalization and case folding.
The explanation opens here once every check passes.