Handle character encodings

Decode bytes of unknown or declared encoding the way a browser would, and inspect what the sniffer chose, with turbohtml.parse() and turbohtml.detect.detect().

Parse bytes of an unknown encoding

turbohtml.parse() accepts bytes and runs the WHATWG encoding sniffing algorithm: a byte-order mark, then the encoding argument, then a <meta> declaration, then a structural UTF-8 check, defaulting to windows-1252. Pass encoding to outrank the <meta> and the sniff – a byte-order mark still outranks it – and read encoding for the WHATWG name that was resolved:

import turbohtml

doc = turbohtml.parse(b'<meta charset="iso-8859-2"><p>\xe1</p>')
print(doc.encoding)
print(doc.find("p").text)
ISO-8859-2
á

Tell a declaration from a guess

encoding_confidence reports which of the sniff’s steps answered. It is "certain" when the document named its own encoding, through a byte-order mark, the encoding argument, or a <meta> charset. It is "tentative" when nothing did and the sniff fell back on a structural UTF-8 read, the opt-in detector, or windows-1252. Chasing a mojibake report, that tells a page that declared the wrong encoding from one that declared none:

import turbohtml

print(turbohtml.parse(b'<meta charset="utf-8"><p>caf\xc3\xa9</p>').encoding_confidence)
print(turbohtml.parse(b"<p>caf\xc3\xa9</p>").encoding_confidence)
print(turbohtml.parse("<p>café</p>").encoding_confidence)
certain
tentative
None

A <meta> charset counts wherever it sits, even past the 1024 bytes the prescan reads: turbohtml.parse() redoes the parse against a declaration it could not reach in time, which is the WHATWG “changing the encoding while parsing” step. turbohtml.detect.detect() has no tree to consult, so it stops at the prescan and can disagree with turbohtml.parse() on such a document; html5lib draws the same line between its input stream’s encoding and its parser’s documentEncoding.

Detect an encoding without parsing

When you only need the encoding, say to decode a file or a response body, run the same sniff standalone with turbohtml.detect.detect(); it replaces chardet.detect and charset_normalizer.from_bytes:

from turbohtml.detect import detect

raw = "Précédemment, la créativité française".encode("cp1252")
match = detect(raw)
print(match.encoding, match.language)
print(raw.decode(match.codec))
windows-1252 None
Précédemment, la créativité française

Decode through match.codec, not match.encoding. The two are different strings: encoding is the WHATWG name, and the CPython codec that answers to the same name is a different encoding – bytes.decode("big5") reaches a strict subset of the spec’s Big5, koi8-u reaches KOI8-U where the spec means KOI8-RU, and x-mac-cyrillic reaches no codec at all. match.codec names a whatwg-* codec turbohtml.detect registers, whose decoder is the one turbohtml.parse() uses, so the text you get back is the text the parser would have seen.

Rank the alternatives with detect_all(), constrain or threshold them with a Detection config, and feed a stream chunk by chunk with a EncodingDetector, which scores each chunk as it arrives and holds a fixed amount of memory whatever the stream’s length. Where the chunks fall never changes the answer:

from io import BytesIO

from turbohtml.detect import EncodingDetector

stream = BytesIO("\ufeffstreamed UTF-8 content".encode())
detector = EncodingDetector()
for chunk in iter(lambda: stream.read(4096), b""):
    detector.feed(chunk)
    if detector.done:  # the byte-order mark already decided the stream
        break
print(detector.close().encoding)
UTF-8-SIG

Tell the detector where the bytes came from

Frequency scoring has little to work with on short text, where several encodings of one script score alike. The host that served the page is evidence a browser already uses. Pass the hostname’s rightmost DNS label as tld and the detector weighs the candidates the way Firefox does: the encoding that domain expects gains a point, the ones native to it keep their score, and the rest pay a penalty.

from turbohtml.detect import Detection, detect

raw = "Příliš žluťoučký kůň úpěl ďábelské ódy".encode("iso-8859-2")
print(detect(raw).encoding)
print(detect(raw, Detection(tld="cz")).encoding)
print(detect(raw, Detection(tld="ru")).encoding)
ISO-8859-2
ISO-8859-2
windows-1252

Give the label alone, lower-case, with no leading dot: "cz", not "example.cz". Use the Punycode form for an internationalized domain, so "xn--p1ai" and not "рф". Anything else raises ValueError, since a misspelled label would otherwise read as no hint at all. A generic label such as "com" says nothing about script and changes nothing, and neither does the default None.

A two-letter label the classifier does not carry reads as Western European, which is still a hint. Above, .ru rules out the Central European candidates and leaves windows-1252 standing. A .th label would rule out nothing, because the bytes hold no Thai for its expectation to rest on.

The hint reaches frequency scoring and stops there. A byte-order mark, a <meta> charset, and structurally valid UTF-8 are proofs rather than guesses, and no domain outvotes them.

Normalize decoded text to a Unicode normalization form

Once bytes are decoded, the same visible text can carry different code points – "é" as one character or as an e plus a combining accent – so equality, search, and deduplication need a normalization pass first. turbohtml.detect.normalize() runs all four UAX #15 forms in C, the successor to unicodedata.normalize():

from turbohtml.detect import is_normalized, normalize

composed = "café"
decomposed = "café"
print(composed == decomposed)
print(normalize("NFC", decomposed) == composed)
print(is_normalized("NFC", decomposed))
print(normalize("NFKC", "file") == "file")
False
True
False
True

Use NFC to compare or store user text, NFKC to additionally flatten presentation variants (ligatures, superscripts, width variants), and the NFD / NFKD forms when you want the fully decomposed representation. is_normalized() answers the membership question without building the normalized copy.