From 275882e88398afa63477eb0eeba049a4f017eea6 Mon Sep 17 00:00:00 2001 From: dataflow-solutions-sk Date: Sun, 16 Aug 2026 21:12:22 +0200 Subject: [PATCH] xlsx: sanitize illegal control/surrogate chars before cell write openpyxl raises IllegalCharacterError (not a subclass of ValueError) when a string cell value contains an XML-illegal ASCII control character, so the existing except ValueError never caught it, and the export crashed instead of writing a sanitized value. Strip these characters using openpyxl's own ILLEGAL_CHARACTERS_RE before assigning to a cell, so behavior stays consistent with whatever openpyxl considers illegal. Also strip lone surrogate characters, which openpyxl accepts silently but which corrupt the resulting XML and make the file unreadable. Fixes jazzband/tablib#370 --- src/tablib/formats/_xlsx.py | 22 +++++++++++++++++++++- tests/test_tablib.py | 10 ++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/tablib/formats/_xlsx.py b/src/tablib/formats/_xlsx.py index b3ad88bd..7bbc3cd2 100644 --- a/src/tablib/formats/_xlsx.py +++ b/src/tablib/formats/_xlsx.py @@ -14,6 +14,7 @@ import re from io import BytesIO +from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE from openpyxl.reader.excel import ExcelReader, load_workbook from openpyxl.styles import Alignment, Font from openpyxl.utils import get_column_letter @@ -23,11 +24,30 @@ INVALID_TITLE_REGEX = re.compile(r'[\\*?:/\[\]]') +# Lone surrogates (e.g. produced by ``surrogateescape`` error handling) +# cannot be encoded to valid XML/UTF-8 either, so they need stripping +# alongside the ASCII control characters openpyxl itself rejects. +SURROGATES_RE = re.compile(r'[\ud800-\udfff]') + def safe_xlsx_sheet_title(s, replace="-"): return re.sub(INVALID_TITLE_REGEX, replace, s)[:31] +def _sanitize_value(value): + """Strip characters that openpyxl/XML cannot represent in a cell. + + openpyxl raises ``IllegalCharacterError`` (not a ``ValueError``) for + strings containing certain ASCII control characters, and silently + writes an unreadable file for lone surrogate characters. Sanitize + both cases up front so the value can be written safely. + """ + if isinstance(value, str): + value = ILLEGAL_CHARACTERS_RE.sub('', value) + value = SURROGATES_RE.sub('', value) + return value + + class XLSXFormat: title = 'xlsx' extensions = ('xlsx',) @@ -182,7 +202,7 @@ def dset_sheet(cls, dataset, ws, freeze_panes=True, escape=False): cell.alignment = wrap_text try: - cell.value = col + cell.value = _sanitize_value(col) except ValueError: cell.value = str(col) diff --git a/tests/test_tablib.py b/tests/test_tablib.py index ca8ac05e..b0d634d7 100755 --- a/tests/test_tablib.py +++ b/tests/test_tablib.py @@ -1644,6 +1644,16 @@ def test_xlsx_raise_ValueError_on_cell_write_during_export(self): wb = load_workbook(filename=BytesIO(_xlsx)) self.assertEqual('[1]', wb.active['A1'].value) + def test_xlsx_export_strips_illegal_characters(self): + """Cell values containing XML-illegal ASCII control characters + must be sanitized rather than raising IllegalCharacterError. + See: https://github.com/jazzband/tablib/issues/370 + """ + data.append((f'a{chr(31)}b',)) + _xlsx = data.export('xlsx') + wb = load_workbook(filename=BytesIO(_xlsx)) + self.assertEqual('ab', wb.active['A1'].value) + def test_xlsx_column_width_adaptive(self): """ Test that column width adapts to value length""" width_before, width_after = self._helper_export_column_width("adaptive")