Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/markitdown/src/markitdown/converters/_csv_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,22 @@ def _escape_table_cell(value: str) -> str:
return value.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")


def _is_blank_row(row: list[str]) -> bool:
"""Whether a parsed row carries no data.

Writers that pad every record to the column width emit a blank row as
",," rather than an empty line -- ``pandas.DataFrame.to_csv`` does this
for all-NaN rows -- so a run of empty (or whitespace-only) cells is the
same blank row. This is the same reading the PDF table builder already
uses in ``_pdf_converter._to_markdown_table``.
"""
return all(not cell.strip() for cell in row)


def _trim_outer_blank_rows(rows: list[list[str]]) -> None:
"""Remove empty rows from the beginning and end, and immediately after the header. This operation is performed in-place."""
start = 0
while start < len(rows) and not rows[start]:
while start < len(rows) and _is_blank_row(rows[start]):

Choose a reason for hiding this comment

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

This also removes legitimate headers whose column names are empty. For example, converting b",\nAlice,30\nBob,40\n" through MarkItDown.convert_stream previously produced:

|  |  |
| --- | --- |
| Alice | 30 |
| Bob | 40 |

On this branch it produces:

| Alice | 30 |
| --- | --- |
| Bob | 40 |

Alice's record is now treated as the header. Quoted empty names have the same problem; a three-column unnamed header also loses its extra column.

Could we preserve field-bearing headers by default and make trimming leading padded records opt-in? Parsed empty fields alone can't distinguish padding from an unnamed header. I reproduced these three regressions on c037894 and checked that they pass on the base.

Tested with Hermes AI assistance.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, and reproduced: all three cases changed on this branch and matched main again once I narrowed the rule. You're right that parsed empty fields alone can't separate padding from an unnamed header — pandas.read_csv reads a first row of , / ,, into Unnamed: 0 / Unnamed: 1, so it resolves the ambiguity as a header. This branch now agrees: the all-empty-cells rule applies only to rows after the header (the run right after it, and the trailing run), and the leading run keeps the old "no fields at all" test. _is_blank_row is now _is_padding_row, with that caveat in its docstring. Done in 93cbcc6.

I dropped the leading trim rather than gating it behind an opt-in, because nothing would set the flag and pandas.to_csv puts its blank rows after the named header — the trailing and after-header cases are the ones that reproduce in practice. If you or a maintainer would still rather trim leading padding behind an option, say so and I'll add it.

The three shapes you listed are now pinned as tests (,, "","", and ,, as the first row, including the column count). pytest tests/test_csv.py -q → 47 passed; over tests/test_csv.py tests/test_module_misc.py tests/test_cu.py → 325 passed here, against 322 passed plus the three trimming failures with only _csv_converter.py put back at 4cc9fa1.

start += 1

if start == len(rows):
Expand All @@ -41,11 +53,11 @@ def _trim_outer_blank_rows(rows: list[list[str]]) -> None:

header_index = start
start += 1
while start < len(rows) and not rows[start]:
while start < len(rows) and _is_blank_row(rows[start]):
start += 1

end = len(rows)
while end > start and not rows[end - 1]:
while end > start and _is_blank_row(rows[end - 1]):
end -= 1

# Remove each blank run at once, rather than shifting the list per row.
Expand Down
37 changes: 37 additions & 0 deletions packages/markitdown/tests/test_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,43 @@ def test_csv_all_blank_input_returns_empty_markdown() -> None:
assert result == ""


def test_csv_comma_padded_blank_rows_are_trimmed() -> None:
# A writer that pads every record to the column width writes a blank row
# as ",," rather than an empty line; pandas' to_csv does this for all-NaN
# rows. Such a row is blank just the same: an outer one must not become an
# empty header, nor survive as an empty data row.
result = _convert_csv(b",,\nname,age\nAlice,30\n,,\n,,\n")

assert result == "| name | age |\n| --- | --- |\n| Alice | 30 |"


def test_csv_whitespace_padded_blank_rows_are_trimmed() -> None:
result = _convert_csv(b" , \nname,age\nAlice,30\n , \n")

assert result == "| name | age |\n| --- | --- |\n| Alice | 30 |"


def test_csv_comma_padded_row_between_data_rows_is_kept() -> None:
# Only the outer runs are trimmed; an internal blank row keeps its place,
# exactly as the existing blank-line test pins for unpadded blank rows.
# (The ",," record carries three empty fields, so it is the widest row.)
result = _convert_csv(b"name,age\nAlice,30\n,,\nBob,40\n")

assert (
result == "| name | age | |\n"
"| --- | --- | --- |\n"
"| Alice | 30 | |\n"
"| | | |\n"
"| Bob | 40 | |"
)


def test_csv_blank_row_after_header_is_trimmed() -> None:
result = _convert_csv(b"name,age\n,,\nAlice,30\n")

assert result == "| name | age |\n| --- | --- |\n| Alice | 30 |"


@pytest.mark.parametrize(
"data,expected",
[
Expand Down