← 输入框|github.com|刷新
Skip to content

fix: stop silently returning mojibake from encoding detection - #4550

Merged
cragwolfe merged 4 commits into
Unstructured-IO:mainfrom
huuufu:huuufu/fix-4466-encoding
Oct 14, 2026
Merged

cragwolfe merged 4 commits into
Unstructured-IO:mainfrom
huuufu:huuufu/fix-4466-encoding

Conversation

@huuufu

@huuufu huuufu commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #4466.

detect_file_encoding() (unstructured/file_utils/encoding.py) turns text into mojibake without raising, by two routes:

  1. It accepts whatever charset_normalizer.detect() returns once confidence clears 0.8. That includes valid UTF-8 whose only non-ASCII content is one invisible character, which the detector reports as a single-byte codec at confidence 1.0 (case from @cdcailf in bug/encoding-detection-returns-mojibake #4466).
  2. On inconclusive detection it takes the first COMMON_ENCODINGS entry that decodes. iso_8859_1 is at index 1 and decodes every byte, so it always wins and the CJK codecs behind it are unreachable.

Everything that reads text through read_txt_file() is affected: partition_text, partition_md, partition_html, partition_xml, and file-type detection.

detect_file_encoding(file=...), main vs this PR:

input bytes main this PR
UTF-8 "…unique.​" cp1125, 'unique.тАЛ' utf-8, correct
UTF-8 "…unique.­" big5, 'unique.簫' utf-8, correct
cp932 髙﨑様と﨑山様… cp932, correct, silent cp932, correct, warns
cp1254 Turkish sentence windows-1254, correct, silent windows-1254, correct, warns
shift_jis 会議室予約 (issue) big5hkscs mojibake, silent same text, warns
latin-1 French (issue) windows-1250 mojibake, silent same text, warns
ASCII, UTF-8, UTF-8 with BOM correct correct, no warning

The detector result for very short inputs varies by charset_normalizer version: with 3.4.7 the zero-width case comes back as cp1125, and #4466 reports mac_iceland with 3.5.2. Either way, the result is wrong on main.

Change

Order of decisions in detect_file_encoding() when no encoding is supplied:

  1. Unicode BOM. UTF-32 is checked before UTF-16 because their little-endian BOMs share a prefix. Malformed data after a BOM raises instead of being retried as another codec.
  2. Strict UTF-8. If non-ASCII bytes decode as UTF-8, the result is UTF-8 and the detector is not consulted. Any single-byte codec decodes these bytes too, but random bytes rarely pass UTF-8 validation, so a successful UTF-8 decode is much stronger evidence. Pure ASCII still goes through the detector, so its reported encoding stays ascii.
  3. Conclusive detector result. It is kept even when it is outside COMMON_ENCODINGS (e.g. cp932, cp1254). The issue's "Additional context" shows that turning that list into an allowlist breaks correctly detected Turkish.
  4. Inconclusive. Candidates are ranked with from_bytes(byte_data, enable_fallback=False).best() instead of taking the first codec that decodes. If nothing survives, the result is UnprocessableEntityError instead of Latin-1 text.
  5. Warning. A new EncodingDetectionWarning(UserWarning) is emitted whenever a legacy encoding or BOM-less UTF-16/32 was inferred statistically. The message contains the codec, never document text.

On the policy question left open in #4466 (which way to be wrong), this PR keeps the existing best-effort output and makes it observable instead of picking a new winner. Correct legacy detections keep working. Callers who prefer failure to possibly wrong text can promote the warning to an exception:

with warnings.catch_warnings():
    warnings.simplefilter("error", EncodingDetectionWarning)
    partition_text(filename=path)  # raises instead of returning guessed text

Callers who know the source encoding pass encoding=, which is authoritative and decoded strictly. partition_md() now passes encoding= through to read_txt_file() for filename/file and uses it to decode URL response bytes. Before this change it was ignored on all three paths.

Compatibility

  • Inputs that previously produced silent Latin-1 output on the inconclusive path can now raise UnprocessableEntityError. This happens when from_bytes() finds no text candidate, i.e. binary-looking input.
  • Applications that run with all warnings as errors will now reject inferred legacy encodings unless they pass encoding= or filter EncodingDetectionWarning.
  • No new dependency, confidence threshold or language-specific allowlist.

Testing

  • test_unstructured/file_utils/test_encoding_detection_policy.py (new). Covers the issue samples via filename, bytes and stream; nine further legacy code pages; valid UTF-8 not overridden by a confident detector (the five characters from bug/encoding-detection-returns-mojibake #4466's table); BOM precedence and malformed BOM payloads; explicit encoding is authoritative; inconclusive ranking; binary rejection; path/stream parity including \r\n/\r; empty input; no warning for UTF-8/ASCII aliases.
  • test_unstructured/partition/test_encoding_policy.py (new). Exercises explicit encoding=, the warning and warning-as-error through partition_text, partition_md and partition_html, plus partition_md(url=..., encoding=...).
  • test_charset_detection_failure no longer patches COMMON_ENCODINGS, so the real fallback path is exercised.
  • pyproject.toml ignores the warning in the test suite by message, on its builtin UserWarning base class. Existing tests on legacy fixtures (e.g. fake-text-utf-16-le.txt) now emit it. In make docker-test the xdist controller cannot import unstructured (--no-install-project), so it crashed unserializing the worker's EncodingDetectionWarning. This is the package's first custom warning class. Tests that check the warning use pytest.warns(), which overrides the filter.
$ pytest test_unstructured/file_utils test_unstructured/partition/test_encoding_policy.py \
    test_unstructured/partition/test_text.py test_unstructured/partition/test_md.py test_unstructured/partition/html
637 passed, 2 failed

The 2 failures (test_partition_text_from_filename_raises_econding_error[fake-text.txt-utf-16-…], test_partition_html_on_ideas_page) fail identically on main (e2f22b38) on the same Windows machine and are unrelated. ruff check . and ruff format --check . are clean.

…ctured-IO#4466)

detect_file_encoding() accepted any confident charset_normalizer result and,
on inconclusive detection, took the first COMMON_ENCODINGS codec that decoded,
which is almost always iso_8859_1. Both paths turned text into mojibake
without raising.

- Honor Unicode BOMs before statistical detection.
- Accept bytes that are valid UTF-8 before consulting the detector, which
  reports mac_iceland / cp850 at confidence 1.0 when the only non-ASCII
  content is a single U+200B or U+00AD.
- Rank inconclusive candidates with from_bytes() instead of taking the first
  decodable codec, and raise when no text candidate survives.
- Emit EncodingDetectionWarning for statistically inferred legacy or BOM-less
  UTF-16/32 results so callers can reject them with a warnings filter.
- Pass partition_md(encoding=...) through to local and URL byte decoding.
make docker-test runs pytest -n auto with --no-install-project, and the xdist
controller there cannot import unstructured, so it crashed unserializing an
EncodingDetectionWarning reported by a worker. Ignore it by message on its
builtin base class; tests that check it use pytest.warns().
@cdcailf

cdcailf commented Oct 13, 2026

Copy link
Copy Markdown

Independent verification, on the environment where charset_normalizer returns the version-dependent answer #4466 describes.

Method: PyPI unstructured 0.27.25 with only this branch's encoding.py swapped in, so every other module is main. Python 3.11.16, charset-normalizer 3.5.2, macOS arm64 — the version where the ZWSP case comes back mac_iceland rather than cp1125.

  • test_unstructured/file_utils/test_encoding_detection_policy.py: 120 passed, 0 failed, including the five-character table across filename / bytes / stream.
  • Ten real-world samples in their source encodings (cp1252, cp1251, cp1250, cp1254, cp1253, cp1256, cp1255, cp874, cp949, cp932) come back identical to main character for character; the only difference is the warning count going 0 → 1. I found no row where main was correct and this branch is not.
  • BOM'd UTF-16/32, utf-8-sig, pure ASCII and empty input: unchanged, still silent.

One thing I would pin with a test: not byte_data.isascii() is load-bearing, and neither the PR description nor the CHANGELOG says so. A BOM-less UTF-16/32 payload whose characters are all below U+0080 is itself valid UTF-8 — every NUL is a valid UTF-8 byte — so the strict-UTF-8 step would claim it and the "BOM-less UTF-16/32 warns" guarantee would not hold. Counterfactual, with only that condition removed:

utf-16-le  detected=utf-8  text_ok=False  warned=0  'h\x00e\x00l\x00l\x00o\x00 \x00w\x00o\x00r\x00l\x00d\x00'
utf-16-be  detected=utf-8  text_ok=False  warned=0  '\x00h\x00e\x00l\x00l\x00o\x00 \x00w\x00o\x00r\x00l\x00d'
utf-32-le  detected=utf-8  text_ok=False  warned=0  'h\x00\x00\x00e\x00\x00\x00l\x00\x00\x00l\x00\x00\x00o\x00...'
utf-32-be  detected=utf-8  text_ok=False  warned=0  '\x00\x00\x00h\x00\x00\x00e\x00\x00\x00l\x00\x00\x00l\x00...'

Unmodified, the same inputs are correct and do warn (identical to main, so this is not a regression today):

input detected text warning
"hello world".encode("utf-16-le") utf-16-le correct yes
"hello world".encode("utf-16-be") utf-16-be correct yes
"hello world".encode("utf-32-le") utf-32-le correct yes
"hello world".encode("utf-32-be") utf-32-be correct yes
"cafe \u4f1a\u8b70".encode("utf-16-le") utf-16-le correct yes

A one-line test on any of those rows would keep a later simplification of that condition from silently reopening it.

Scope note: the two partition-level test modules need the rest of the patch. Against the partial tree above they fail identically with main's encoding.py, so they are not a signal either way.

@cragwolfe cragwolfe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT Pro review of c294f4a found two actionable Unicode decoding defects, detailed inline:

  1. BOM-less UTF-16/32 can pass the new UTF-8 shortcut and return corrupted text without the inferred-encoding warning.
  2. Strict BOM decoding can reject a valid UTF-16 upload when file-type detection splits a surrogate pair at its 4,096-byte read boundary.

Both findings were verified against the changed code and its callers. All 52 reported checks on this exact head are successful, including CI unit tests for Python 3.11–3.13. Validation here was source and checked-in test inspection; no local tests were run. These cases need fixes and focused regression coverage before approval.

(authored by codex)

Comment thread unstructured/file_utils/encoding.py Outdated
f"File encoding detection failed: detected '{encoding}' but decode failed. "
f"File may be corrupted or in an unsupported format."
) from None
byte_data.decode("utf-8")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep BOM-less UTF-16/32-shaped data out of the unconditional UTF-8 shortcut

For example, "Meeting notes: \u8acb\n".encode("utf-16-le") has no BOM, contains non-ASCII bytes, and is also syntactically valid UTF-8: ASCII characters contribute a trailing NUL, while U+8ACB contributes CB 8A, which UTF-8 interprets as U+02CA. This branch therefore selects UTF-8 before a correct UTF-16 detector result can be considered. The returned text contains NUL padding and the wrong character; the warning at lines 148–155 is also suppressed. BOM-less UTF-32LE has the same overlap. read_txt_file() and partition_text() consume the corrupted result, and warning-as-error callers cannot reject it.

Guard against the NUL-padding patterns of UTF-16/32 before taking this shortcut, so these buffers can reach statistical detection and retain the inferred-encoding warning. Add filename/bytes/stream coverage for this overlap, including a supplied correct detector result and warning-as-error behavior, while retaining the genuine UTF-8 precedence tests.

(authored by codex)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in bf795c2. On c294f4a, "Meeting notes: 請\n" in BOM-less UTF-16LE and UTF-32LE came back as utf-8 with NUL padding and no warning. main gets both right, so this was a regression from this PR.

The UTF-8 check now also requires b"\x00" not in byte_data. BOM-less UTF-16/32 always NUL-pads ASCII characters, while real UTF-8 text essentially never contains NUL. A NUL-bearing buffer that really is UTF-8 just falls back to the detector, as on main. After the fix, all four of UTF-16LE/BE and UTF-32LE/BE, for both that string and pure-ASCII "hello world", come back with the right codec and text and warn.

Tests in test_encoding_detection_policy.py:

  • test_bom_less_utf16_and_utf32_are_not_claimed_by_the_utf8_check: both strings × 4 codecs × filename/bytes/stream, with the correct detector result supplied, asserting the text and the warning.
  • test_callers_can_reject_bom_less_utf16_and_utf32: warning-as-error raises.
  • The existing UTF-8 precedence tests (test_valid_utf8_is_not_overridden_by_a_confident_detector, the BOM tests) are unchanged and pass.

Comment thread unstructured/file_utils/encoding.py Outdated

# Do not expose a UnicodeError's input payload in the public exception.
try:
file_text = byte_data.decode(encoding)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the incomplete-prefix distinction when decoding a detected BOM

_FileTypeDetectionContext.text_head reads only 4,096 bytes from streams (filetype.py:685–709). With the default UTF-8 encoding, a UTF-16 BOM makes both initial decoding attempts fail, so the bounded prefix reaches this strict whole-buffer decode without its eof_reached flag.

A valid payload b"\xff\xfe" + ("A" * 2046 + "\U0001f600" + "\n").encode("utf-16-le") places the emoji's high surrogate at the end of that prefix and its low surrogate in the remaining stream. The new forced UTF-16 decode raises UnprocessableEntityError, even though the complete document is valid. On the textual MIME path, file-type detection accesses text_head before the .txt extension shortcut, so this can stop valid uploads before partitioning. The filename path reads the complete document and avoids this artificial truncation.

Use BOM-aware incremental decoding for prefix inspection with final=eof_reached, while keeping complete-document decoding strict. Cover UTF-16LE/BE surrogate splits, stream-position restoration, and genuinely truncated EOF input. The existing boundary test covers UTF-8 under the initial decoder, not this BOM fallback.

(authored by codex)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in bf795c2. With b"\xff\xfe" + ("A" * 2046 + "\U0001f600\n").encode("utf-16-le") (and the BE equivalent), text_head raised UnprocessableEntityError on c294f4a.

detect_file_encoding() takes a new keyword-only final: bool = True. With final=False, the decode, and the UTF-8 check before it, use the codec's incremental decoder with final=False, so a multi-byte sequence cut off at the end is left out. An invalid byte anywhere else still fails. text_head passes final=eof_reached, the same distinction its own incremental fallback already makes one step earlier. Every other caller decodes a complete document and keeps the strict default. This covers the BOM case and also any detected multi-byte codec whose sequence the prefix splits.

Tests:

  • test_filetype.py, and_it_does_not_mistake_a_boundary_split_surrogate_pair_for_a_decode_error: UTF-16LE/BE, asserts text_head == "a" * 2046 and that the stream position is restored to 0.
  • test_filetype.py, but_it_rejects_a_utf16_stream_that_ends_inside_a_surrogate_pair: a genuinely truncated stream at EOF still raises.
  • test_encoding_detection_policy.py: final=False on UTF-16LE/BE, UTF-8-with-BOM and BOM-less UTF-8 prefixes, plus a check that final=False still rejects an invalid byte before the end.

# Conflicts:
#	CHANGELOG.md
#	unstructured/__version__.py
- Skip the strict UTF-8 shortcut when the bytes contain NUL. BOM-less
  UTF-16/32 pads ASCII with NUL, and with a non-ASCII character such as
  U+8ACB (`CB 8A` in UTF-16LE) the whole buffer is also valid UTF-8, so it
  was decoded as UTF-8 with NUL padding and no EncodingDetectionWarning.
- Add a keyword-only `final` to detect_file_encoding(). With final=False a
  multi-byte sequence cut off at the end is left out instead of failing.
  _FileTypeDetectionContext.text_head passes final=eof_reached, so a UTF-16
  surrogate pair split at the 4,096-byte stream prefix after a BOM no longer
  raises UnprocessableEntityError. Complete-document decoding stays strict.
@huuufu

huuufu commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@cdcailf thanks for the independent run on 3.5.2. Your ASCII-only UTF-16/32 rows are now pinned: test_bom_less_utf16_and_utf32_are_not_claimed_by_the_utf8_check uses "hello world" in all four codecs.

The review above found the case your counterfactual points toward, and it already fails on this branch with the isascii() guard in place: BOM-less UTF-16/32 that does contain a non-ASCII character, e.g. "Meeting notes: 請\n" in UTF-16LE. The guard that carries the weight is now b"\x00" not in byte_data, with isascii() kept only so pure ASCII keeps its ascii label. bf795c2 fixes this, and the CHANGELOG entry now says so.

@cragwolfe both P2 findings are addressed in bf795c2 (replies inline). The branch also merges current main at the 0.27.27-dev3 changelog/version convention.

@cdcailf

cdcailf commented Oct 14, 2026

Copy link
Copy Markdown

@huuufu reran bf795c2 locally — PyPI unstructured 0.27.25 with this branch's encoding.py swapped in, charset-normalizer 3.5.2, Python 3.11.16, macOS arm64:

  • test_unstructured/file_utils/test_encoding_detection_policy.py: 157 passed, 0 failed.
  • Both NUL-padded strings now reach the detector across all four BOM-less codecs, and warning-as-error raises as intended.
  • Side effect worth noting: "会議室予約\n" in BOM-less UTF-16LE now returns utf-16-le with correct text and a warning. On main that input was iso-8859-1 mojibake, so the guard fixes a case the issue never reported.

One boundary note, information only — no change requested. The gap the guard leaves is the no-NUL buffer that is itself valid UTF-8: "請請".encode("utf-16-le") is CB 8A CB 8A, i.e. valid UTF-8 for U+02CA U+02CA, with no NUL padding to trip the new condition. The shortcut claims it and the text comes back 'ˊˊ'. I ran the same input against main: its detector reports utf-8 too (it does its own UTF-8 validation), so the output is identical and this is not a regression from this branch. I mention it only because the isascii() / NUL pair reads as excluding UTF-16/32-shaped data as a class, and this subclass sits outside both tests.

@cragwolfe cragwolfe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GPT Pro review of bf795c2 found no actionable defects in the updated change.

Both findings from the previous review are resolved for their reported failure scenarios:

  • NUL-bearing BOM-less UTF-16/32 input now reaches encoding detection instead of being claimed by the UTF-8 shortcut. Regression tests cover both byte orders, filename/bytes/stream inputs, and warning-as-error behavior.
  • File-type inspection now passes the EOF distinction to incremental decoding, so a UTF-16 surrogate pair split at the 4,096-byte boundary does not reject a valid upload. Tests cover both byte orders, stream-position restoration, and rejection of genuinely truncated EOF input. Complete-document decoding remains strict.

Validation was inspection of the full updated implementation, relevant callers, and checked-in tests; no local tests were run. At publication, this head has 44 successful checks, seven still in progress, and one skipped. This approval covers the code review; the remaining CI results are still pending.

(authored by codex)

@cragwolfe
cragwolfe merged commit 0b7f806 into Unstructured-IO:main Oct 14, 2026
54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug/encoding-detection-returns-mojibake

3 participants