Skip to content

Commit 96c6b52

Browse files
sesharifclaude
andcommitted
Fix UnicodeDecodeError when non-ASCII text falls past the sniffed prefix
The charset is sniffed from the first 4k of a stream but then used to decode all of it, so a mostly-ASCII document whose first non-ASCII character appears beyond that window fails to convert. Running `markitdown README.md` in this repo reproduces it: the first em dash sits at byte 5030, the prefix sniffs as ASCII, and the conversion dies with "'ascii' codec can't decode byte 0xe2 in position 5030". Fixed in two places: - Widen an ASCII guess to UTF-8 in _get_stream_info_guesses. ASCII is a strict subset of UTF-8, so genuine ASCII decodes identically, while UTF-8 content beyond the sniffed prefix now decodes correctly. This also matters because the charset is part of the StreamInfo identity used to decide which guesses are compatible, not just how bytes are decoded. - Add decode_bytes(), which tries the declared charset, re-detects over the full data if that fails, and finally falls back to a lossy decode. A charset can also arrive already declared -- from a Content-Type header, or from the caller -- in which case it is trusted without being sniffed, and can be wrong in the same way. Wired into the three converters that decode a whole stream with such a charset: plain text, CSV, and ipynb. The HTML-family converters pass the charset to BeautifulSoup as from_encoding, which recovers on its own, and are left alone. _normalize_charset now delegates to the shared helper rather than duplicating it. Two test vectors asserted charset="ascii" for test.json and test_notebook.ipynb. Both fixtures are pure ASCII, so UTF-8 decodes them identically; the expectations are updated to match the widened guess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9dc0d65 commit 96c6b52

7 files changed

Lines changed: 206 additions & 23 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import codecs
2+
3+
from typing import Optional
4+
from charset_normalizer import from_bytes
5+
6+
7+
def normalize_charset(charset: Optional[str]) -> Optional[str]:
8+
"""
9+
Normalize a charset string to a canonical form.
10+
"""
11+
if charset is None:
12+
return None
13+
try:
14+
return codecs.lookup(charset).name
15+
except LookupError:
16+
return charset
17+
18+
19+
def decode_bytes(data: bytes, charset: Optional[str] = None) -> str:
20+
"""
21+
Decode data to text, tolerating an incorrect charset.
22+
23+
Charsets reaching the converters are guesses: sniffed from the first few
24+
kilobytes of a stream, or read from a Content-Type header that may be wrong.
25+
Decoding an entire document with such a guess can fail on bytes the guess
26+
never saw -- e.g. an otherwise-ASCII file whose first non-ASCII character
27+
appears past the sniffed window. Rather than fail the conversion, re-detect
28+
over the full data, and finally fall back to a lossy decode.
29+
"""
30+
if charset:
31+
try:
32+
return data.decode(charset)
33+
except (UnicodeDecodeError, LookupError):
34+
pass
35+
36+
detected = from_bytes(data).best()
37+
if detected is not None:
38+
return str(detected)
39+
40+
return data.decode("utf-8", errors="replace")

packages/markitdown/src/markitdown/_markitdown.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import requests
1515
import magika
1616
import charset_normalizer
17-
import codecs
1817

1918
from ._stream_info import StreamInfo
2019
from ._uri_utils import parse_data_uri, file_uri_to_path
@@ -44,6 +43,7 @@
4443

4544
from ._base_converter import DocumentConverter, DocumentConverterResult
4645

46+
from ._charset_utils import normalize_charset
4747
from ._exceptions import (
4848
FileConversionException,
4949
UnsupportedFormatException,
@@ -734,6 +734,14 @@ def _get_stream_info_guesses(
734734
if charset_result is not None:
735735
charset = self._normalize_charset(charset_result.encoding)
736736

737+
# The charset was sniffed from a prefix of the stream, but
738+
# is used to decode all of it. ASCII is a strict subset of
739+
# UTF-8, so widening the guess decodes genuine ASCII
740+
# identically, while also handling a stream whose first
741+
# non-ASCII character falls beyond the sniffed prefix.
742+
if charset == self._normalize_charset("ascii"):
743+
charset = self._normalize_charset("utf-8")
744+
737745
# Normalize the first extension listed
738746
guessed_extension = None
739747
if len(result.prediction.output.extensions) > 0:
@@ -798,9 +806,4 @@ def _normalize_charset(self, charset: str | None) -> str | None:
798806
"""
799807
Normalize a charset string to a canonical form.
800808
"""
801-
if charset is None:
802-
return None
803-
try:
804-
return codecs.lookup(charset).name
805-
except LookupError:
806-
return charset
809+
return normalize_charset(charset)

packages/markitdown/src/markitdown/converters/_csv_converter.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import csv
22
import io
33
from typing import BinaryIO, Any
4-
from charset_normalizer import from_bytes
4+
from .._charset_utils import decode_bytes
55
from .._base_converter import DocumentConverter, DocumentConverterResult
66
from .._stream_info import StreamInfo
77

@@ -42,10 +42,7 @@ def convert(
4242
**kwargs: Any, # Options to pass to the converter
4343
) -> DocumentConverterResult:
4444
# Read the file content
45-
if stream_info.charset:
46-
content = file_stream.read().decode(stream_info.charset)
47-
else:
48-
content = str(from_bytes(file_stream.read()).best())
45+
content = decode_bytes(file_stream.read(), stream_info.charset)
4946

5047
# Parse CSV content
5148
reader = csv.reader(io.StringIO(content))

packages/markitdown/src/markitdown/converters/_ipynb_converter.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import BinaryIO, Any
22
import json
33

4+
from .._charset_utils import decode_bytes
45
from .._base_converter import DocumentConverter, DocumentConverterResult
56
from .._exceptions import FileConversionException
67
from .._stream_info import StreamInfo
@@ -32,8 +33,9 @@ def accepts(
3233
# Read further to see if it's a notebook
3334
cur_pos = file_stream.tell()
3435
try:
35-
encoding = stream_info.charset or "utf-8"
36-
notebook_content = file_stream.read().decode(encoding)
36+
notebook_content = decode_bytes(
37+
file_stream.read(), stream_info.charset
38+
)
3739
return (
3840
"nbformat" in notebook_content
3941
and "nbformat_minor" in notebook_content
@@ -50,8 +52,7 @@ def convert(
5052
**kwargs: Any, # Options to pass to the converter
5153
) -> DocumentConverterResult:
5254
# Parse and convert the notebook
53-
encoding = stream_info.charset or "utf-8"
54-
notebook_content = file_stream.read().decode(encoding=encoding)
55+
notebook_content = decode_bytes(file_stream.read(), stream_info.charset)
5556
return self._convert(json.loads(notebook_content))
5657

5758
def _convert(self, notebook_content: dict) -> DocumentConverterResult:

packages/markitdown/src/markitdown/converters/_plain_text_converter.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import sys
22

33
from typing import BinaryIO, Any
4-
from charset_normalizer import from_bytes
4+
from .._charset_utils import decode_bytes
55
from .._base_converter import DocumentConverter, DocumentConverterResult
66
from .._stream_info import StreamInfo
77

@@ -63,9 +63,6 @@ def convert(
6363
stream_info: StreamInfo,
6464
**kwargs: Any, # Options to pass to the converter
6565
) -> DocumentConverterResult:
66-
if stream_info.charset:
67-
text_content = file_stream.read().decode(stream_info.charset)
68-
else:
69-
text_content = str(from_bytes(file_stream.read()).best())
66+
text_content = decode_bytes(file_stream.read(), stream_info.charset)
7067

7168
return DocumentConverterResult(markdown=text_content)

packages/markitdown/tests/_test_vectors.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ class FileTestVector(object):
155155
FileTestVector(
156156
filename="test.json",
157157
mimetype="application/json",
158-
charset="ascii",
158+
charset="utf-8",
159159
url=None,
160160
must_include=[
161161
"5b64c88c-b3c3-4510-bcb8-da0b200602d8",
@@ -178,7 +178,7 @@ class FileTestVector(object):
178178
FileTestVector(
179179
filename="test_notebook.ipynb",
180180
mimetype="application/json",
181-
charset="ascii",
181+
charset="utf-8",
182182
url=None,
183183
must_include=[
184184
"# Test Notebook",
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3 -m pytest
2+
import io
3+
import json
4+
5+
import pytest
6+
7+
from markitdown import MarkItDown, StreamInfo
8+
from markitdown._charset_utils import decode_bytes, normalize_charset
9+
10+
# Longer than the 4k prefix that _get_stream_info_guesses sniffs for a charset,
11+
# so the non-ASCII character below lands outside the sniffed window.
12+
_PREFIX_PADDING = "word " * 1200
13+
14+
# Sits beyond _PREFIX_PADDING, and is not representable in ASCII or cp1252-as-ASCII.
15+
_LATE_CHARACTER = "—" # em dash
16+
17+
18+
def _convert_bytes(data: bytes, extension: str) -> str:
19+
markitdown = MarkItDown()
20+
return markitdown.convert(
21+
io.BytesIO(data), stream_info=StreamInfo(extension=extension)
22+
).markdown
23+
24+
25+
def _convert_declared(data: bytes, extension: str, charset: str) -> str:
26+
"""Convert with a charset declared up front, as a Content-Type header would."""
27+
markitdown = MarkItDown()
28+
return markitdown.convert(
29+
io.BytesIO(data), stream_info=StreamInfo(extension=extension, charset=charset)
30+
).markdown
31+
32+
33+
def test_decode_bytes_honors_correct_charset():
34+
assert decode_bytes("café".encode("utf-8"), "utf-8") == "café"
35+
assert decode_bytes("café".encode("cp1252"), "cp1252") == "café"
36+
37+
38+
def test_decode_bytes_recovers_from_wrong_charset():
39+
"""A charset that cannot decode the data must not raise."""
40+
assert decode_bytes("café".encode("utf-8"), "ascii") == "café"
41+
42+
43+
def test_decode_bytes_recovers_from_unknown_charset():
44+
assert decode_bytes(b"plain text", "not-a-real-codec") == "plain text"
45+
46+
47+
def test_decode_bytes_without_charset_detects():
48+
assert decode_bytes("café".encode("utf-8")) == "café"
49+
50+
51+
@pytest.mark.parametrize(
52+
"data",
53+
[
54+
b"valid ascii tail \xc3\x28", # truncated utf-8 sequence
55+
b"valid ascii tail \xff\xff\xff",
56+
b"\xff\xfe\x00 utf-16 byte order mark",
57+
b"",
58+
],
59+
)
60+
def test_decode_bytes_never_raises(data):
61+
"""Undecodable bytes degrade to a lossy decode rather than failing the conversion."""
62+
assert isinstance(decode_bytes(data, "utf-8"), str)
63+
assert isinstance(decode_bytes(data), str)
64+
65+
66+
def test_decode_bytes_preserves_decodable_text_around_bad_bytes():
67+
assert "valid ascii tail" in decode_bytes(b"valid ascii tail \xc3\x28", "utf-8")
68+
69+
70+
def test_normalize_charset():
71+
assert normalize_charset(None) is None
72+
assert normalize_charset("UTF8") == normalize_charset("utf-8")
73+
assert normalize_charset("not-a-real-codec") == "not-a-real-codec"
74+
75+
76+
def test_non_ascii_beyond_sniffed_prefix():
77+
"""
78+
Regression: the charset is sniffed from the first 4k of the stream but used
79+
to decode all of it, so a mostly-ASCII document whose first non-ASCII
80+
character appears past that window used to fail with a UnicodeDecodeError.
81+
"""
82+
content = _PREFIX_PADDING + _LATE_CHARACTER + " tail"
83+
result = _convert_bytes(content.encode("utf-8"), ".txt")
84+
assert _LATE_CHARACTER in result
85+
assert "tail" in result
86+
87+
88+
# A charset can also arrive already declared -- from a Content-Type header, or
89+
# from the caller -- in which case it is trusted without being sniffed at all.
90+
# These cover each converter that decodes a whole stream with such a charset.
91+
92+
93+
def test_declared_charset_too_narrow_plain_text():
94+
content = _PREFIX_PADDING + _LATE_CHARACTER
95+
result = _convert_declared(content.encode("utf-8"), ".txt", "ascii")
96+
assert _LATE_CHARACTER in result
97+
98+
99+
def test_declared_charset_too_narrow_csv():
100+
content = "header\n" + _PREFIX_PADDING + "\n" + _LATE_CHARACTER + "\n"
101+
result = _convert_declared(content.encode("utf-8"), ".csv", "ascii")
102+
assert _LATE_CHARACTER in result
103+
104+
105+
def test_declared_charset_too_narrow_ipynb():
106+
notebook = {
107+
"nbformat": 4,
108+
"nbformat_minor": 5,
109+
"metadata": {},
110+
"cells": [
111+
# Padded so the non-ASCII cell below falls outside the sniffed prefix,
112+
# leaving the declared charset the only one in play.
113+
{
114+
"cell_type": "markdown",
115+
"metadata": {},
116+
"source": [_PREFIX_PADDING],
117+
},
118+
{
119+
"cell_type": "markdown",
120+
"metadata": {},
121+
"source": [f"late {_LATE_CHARACTER} character"],
122+
},
123+
],
124+
}
125+
data = json.dumps(notebook, ensure_ascii=False).encode("utf-8")
126+
result = _convert_declared(data, ".ipynb", "ascii")
127+
assert _LATE_CHARACTER in result
128+
129+
130+
def test_pure_ascii_still_round_trips():
131+
content = _PREFIX_PADDING + "plain ascii tail"
132+
result = _convert_bytes(content.encode("ascii"), ".txt")
133+
assert "plain ascii tail" in result
134+
135+
136+
def test_non_utf8_charset_is_not_clobbered():
137+
"""Widening applies only to an ASCII guess -- other charsets are preserved."""
138+
markitdown = MarkItDown()
139+
content = "テスト " + _PREFIX_PADDING
140+
stream = io.BytesIO(content.encode("cp932"))
141+
guesses = markitdown._get_stream_info_guesses(
142+
stream, base_guess=StreamInfo(extension=".txt")
143+
)
144+
assert normalize_charset(guesses[0].charset) == normalize_charset("cp932")
145+
assert "テスト" in markitdown.convert(stream, file_extension=".txt").markdown

0 commit comments

Comments
 (0)