Skip to content

Commit 0f26ef9

Browse files
he-yufengafourney
andauthored
fix(xlsx): tolerate legacy showZeroes sheet views (#2064)
* fix(xlsx): tolerate legacy showZeroes sheet views * Narrow the find/replace of showZeros --------- Co-authored-by: afourney <adamfo@microsoft.com>
1 parent 22db170 commit 0f26ef9

2 files changed

Lines changed: 124 additions & 2 deletions

File tree

packages/markitdown/src/markitdown/converters/_xlsx_converter.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import io
2+
import re
13
import sys
4+
import zipfile
25
from typing import BinaryIO, Any
36
from ._html_converter import HtmlConverter
47
from .._base_converter import DocumentConverter, DocumentConverterResult
@@ -33,6 +36,54 @@
3336
ACCEPTED_XLS_FILE_EXTENSIONS = [".xls"]
3437

3538

39+
# Some producers write the legacy attribute "showZeroes" on <sheetView>, where the
40+
# schema calls it "showZeros". openpyxl rejects the unknown attribute outright, so the
41+
# workbook is repaired by renaming it. The rename is confined to <sheetView> start tags.
42+
_SHEET_VIEW_START_TAG = re.compile(rb"<sheetView(?=[\s/>])[^>]*>")
43+
_SHOW_ZEROES_ATTRIBUTE = re.compile(rb"(?<=[\s])showZeroes(\s*=)")
44+
45+
46+
def _read_xlsx_sheets(file_stream: BinaryIO) -> dict[str, Any]:
47+
start_pos = file_stream.tell()
48+
try:
49+
return pd.read_excel(file_stream, sheet_name=None, engine="openpyxl")
50+
except TypeError as exc:
51+
if "showZeroes" not in str(exc):
52+
raise
53+
54+
repaired_stream = _repair_sheetview_show_zeroes(file_stream, start_pos)
55+
return pd.read_excel(repaired_stream, sheet_name=None, engine="openpyxl")
56+
57+
58+
def _rename_show_zeroes_attribute(data: bytes) -> bytes:
59+
return _SHEET_VIEW_START_TAG.sub(
60+
lambda match: _SHOW_ZEROES_ATTRIBUTE.sub(rb"showZeros\1", match.group(0)),
61+
data,
62+
)
63+
64+
65+
def _repair_sheetview_show_zeroes(
66+
file_stream: BinaryIO, start_pos: int = 0
67+
) -> io.BytesIO:
68+
file_stream.seek(start_pos)
69+
repaired_stream = io.BytesIO()
70+
71+
with zipfile.ZipFile(file_stream) as source:
72+
with zipfile.ZipFile(repaired_stream, "w", zipfile.ZIP_DEFLATED) as target:
73+
for item in source.infolist():
74+
data = source.read(item.filename)
75+
if (
76+
item.filename.startswith("xl/worksheets/")
77+
and item.filename.endswith(".xml")
78+
and b"showZeroes" in data
79+
):
80+
data = _rename_show_zeroes_attribute(data)
81+
target.writestr(item, data)
82+
83+
repaired_stream.seek(0)
84+
return repaired_stream
85+
86+
3687
class XlsxConverter(DocumentConverter):
3788
"""
3889
Converts XLSX files to Markdown, with each sheet presented as a separate Markdown table.
@@ -80,7 +131,7 @@ def convert(
80131
_xlsx_dependency_exc_info[2]
81132
)
82133

83-
sheets = pd.read_excel(file_stream, sheet_name=None, engine="openpyxl")
134+
sheets = _read_xlsx_sheets(file_stream)
84135
md_content = ""
85136
for s in sheets:
86137
md_content += f"## {s}\n"

packages/markitdown/tests/test_module_misc.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import re
77
import shutil
88
import zipfile
9-
from types import SimpleNamespace
109
import pytest
10+
from types import SimpleNamespace
1111
from unittest.mock import MagicMock
1212

1313
import markitdown._uri_utils as uri_utils
@@ -374,6 +374,77 @@ def test_docx_equations() -> None:
374374
assert block_equations, "No block equations found in the document."
375375

376376

377+
def test_xlsx_legacy_show_zeroes_sheetview(tmp_path) -> None:
378+
from openpyxl import Workbook
379+
380+
base_path = tmp_path / "base.xlsx"
381+
xlsx_path = tmp_path / "legacy_show_zeroes.xlsx"
382+
383+
workbook = Workbook()
384+
sheet = workbook.active
385+
sheet.title = "Data"
386+
sheet["A1"] = "hello"
387+
sheet["B1"] = "world"
388+
# A cell whose text is byte-identical to the malformed attribute. openpyxl stores it
389+
# as an inline string, so it lands in the very worksheet part that gets repaired.
390+
sheet["A2"] = ' showZeroes="0" '
391+
workbook.save(base_path)
392+
393+
with zipfile.ZipFile(base_path) as source:
394+
with zipfile.ZipFile(xlsx_path, "w", zipfile.ZIP_DEFLATED) as target:
395+
for item in source.infolist():
396+
data = source.read(item.filename)
397+
if item.filename == "xl/worksheets/sheet1.xml":
398+
data = data.replace(
399+
b"<sheetView ", b'<sheetView showZeroes="0" ', 1
400+
)
401+
# Guard the fixture: the sheet view attribute and the cell text must
402+
# both live here, or this test stops exercising the repair.
403+
assert data.count(b'showZeroes="0"') == 2
404+
target.writestr(item, data)
405+
406+
result = MarkItDown().convert(str(xlsx_path))
407+
408+
assert "## Data" in result.markdown
409+
assert "hello" in result.markdown
410+
assert "world" in result.markdown
411+
412+
# The repair must not reach into the worksheet's data: the cell keeps its text ...
413+
assert 'showZeroes="0"' in result.markdown
414+
# ... and no part of the document was silently renamed on the way through.
415+
assert "showZeros" not in result.markdown
416+
417+
418+
def test_xlsx_show_zeroes_rename_is_scoped_to_sheet_view_tags() -> None:
419+
from markitdown.converters._xlsx_converter import _rename_show_zeroes_attribute
420+
421+
worksheet_xml = (
422+
b"<worksheet><sheetViews>"
423+
b'<sheetView showZeroes="0" workbookViewId="0"/>'
424+
b'<sheetView\n\tshowZeroes="1"\ttabSelected="1"/>'
425+
b"</sheetViews>"
426+
# openpyxl ignores custom sheet views entirely, so they need no repair
427+
b'<customSheetViews><customSheetView showZeroes="0"/></customSheetViews>'
428+
b'<sheetData><row r="1">'
429+
b'<c r="A1" t="inlineStr"><is><t xml:space="preserve"> showZeroes="0" '
430+
b"</t></is></c>"
431+
b'<c r="B1"><f>IF(A1=" showZeroes=","x","y")</f><v>y</v></c>'
432+
b"</row></sheetData></worksheet>"
433+
)
434+
435+
repaired = _rename_show_zeroes_attribute(worksheet_xml)
436+
437+
# Every <sheetView> start tag is repaired, whatever separates its attributes ...
438+
assert repaired.count(b"showZeros=") == 2
439+
assert b'<sheetView showZeros="0" workbookViewId="0"/>' in repaired
440+
assert b'<sheetView\n\tshowZeros="1"\ttabSelected="1"/>' in repaired
441+
442+
# ... and nothing outside those start tags is rewritten.
443+
assert b'<t xml:space="preserve"> showZeroes="0" </t>' in repaired
444+
assert b'<f>IF(A1=" showZeroes=","x","y")</f>' in repaired
445+
assert b'<customSheetView showZeroes="0"/>' in repaired
446+
447+
377448
def test_input_as_strings() -> None:
378449
markitdown = MarkItDown()
379450

0 commit comments

Comments
 (0)