Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format

## Unreleased

### Fixed
- Fix `extract_table` dropping columns when `vertical_strategy="explicit"` is paired with text- or lines-derived horizontal edges that don't span the full range of the explicit vertical lines (and symmetrically for explicit horizontal lines). ([#1335](https://github.com/jsvine/pdfplumber/issues/1335))

### Changed
- Upgrade `pdfminer.six` from `20251230` to `20260107`. ([07a5ff6](https://github.com/jsvine/pdfplumber/commit/07a5ff6))

## 0.11.9 — 2026-01-05
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ Many thanks to the following users who've contributed ideas, features, and fixes
- [Brandon Roberts](https://github.com/brandonrobertz)
- [@ennamarie19](https://github.com/ennamarie19)
- [Anton Ilin](https://github.com/bronislav)
- [Filippo Mattia Menghi](https://github.com/Cyberfilo)

## Contributing

Expand Down
25 changes: 25 additions & 0 deletions pdfplumber/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,31 @@ def get_edges(self) -> T_obj_list:

h = h_base + h_explicit

# Make sure horizontal edges span the x-range of any explicit vertical
# lines (and vertical edges span the y-range of any explicit horizontal
# lines), so that columns/rows defined by user-supplied lines aren't
# dropped at the intersection step when the surrounding text or detected
# lines don't reach them. See issue #1335.
if v_explicit and h:
vx_min = min(e["x0"] for e in v_explicit)
vx_max = max(e["x0"] for e in v_explicit)
for e in h:
if e["x0"] > vx_min:
e["x0"] = vx_min
if e["x1"] < vx_max:
e["x1"] = vx_max
e["width"] = e["x1"] - e["x0"]

if h_explicit and v:
hy_min = min(e["top"] for e in h_explicit)
hy_max = max(e["bottom"] for e in h_explicit)
for e in v:
if e["top"] > hy_min:
e["top"] = hy_min
if e["bottom"] < hy_max:
e["bottom"] = hy_max
e["height"] = e["bottom"] - e["top"]

edges = list(v) + list(h)

edges = merge_edges(
Expand Down
72 changes: 72 additions & 0 deletions tests/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,78 @@ def test_text_without_words(self):
assert table.words_to_edges_h([]) == []
assert table.words_to_edges_v([]) == []

def test_issue_1335_explicit_outside_text_span(self):
"""
See issue #1335. When explicit vertical lines fall outside the
natural x-span of text-derived horizontal edges, the corresponding
columns must still be recovered.
"""

class FakePage:
def __init__(self, words, bbox=(0, 0, 600, 800)):
self._words = words
self.bbox = bbox
self.edges = []

def extract_words(self, **_kwargs):
return self._words

def word(text, x0, top):
width = 8 * len(text)
return {
"text": text,
"x0": x0,
"x1": x0 + width,
"top": top,
"bottom": top + 12,
"doctop": top,
"upright": True,
"height": 12,
"width": width,
"fontname": "Helvetica",
"size": 10,
}

# Three rows of three columns; the last column's text is narrow
# and ends well before the rightmost explicit vertical (x=500).
# The leftmost explicit vertical (x=50) sits to the left of any
# text. Both columns must still be detected.
rows = [
[("Alpha", 60), ("100", 210), ("X", 360)],
[("Beta", 60), ("200", 210), ("YY", 360)],
[("Gamma", 60), ("300", 210), ("Z", 360)],
]
words = [
word(text, x0, 100 + 30 * row_idx)
for row_idx, row in enumerate(rows)
for text, x0 in row
]
page = FakePage(words)
tf = table.TableFinder(
page,
{
"vertical_strategy": "explicit",
"horizontal_strategy": "text",
"explicit_vertical_lines": [50, 200, 350, 500],
"intersection_tolerance": 5,
"snap_tolerance": 3,
"join_tolerance": 5,
},
)
xs = sorted({p[0] for p in tf.intersections})
assert xs == [50, 200, 350, 500]
assert len(tf.tables) == 1
# 3 data rows × 3 columns of cells must all be present.
cell_xs_by_row = {}
for x0, top, _x1, _bottom in tf.tables[0].cells:
cell_xs_by_row.setdefault(top, set()).add(x0)
full_rows = [
top
for top, xs_in_row in cell_xs_by_row.items()
if {50, 200, 350}.issubset(xs_in_row)
]
assert len(full_rows) >= 3

def test_order(self):
"""
See issue #336
Expand Down