Skip to content
Merged
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
75 changes: 66 additions & 9 deletions src/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,30 @@ def to_textmap(
return TextMap(_textmap)


def _line_top(obj):
"""Vertical position used to group chars and words into text lines.

make_chars() stores "line_top" for every char: the top of the text line
computed from the char's baseline and font metrics, i.e. where the quad
correction with small glyph heights would put the top of the char box. It
is the same for every char on a line whether or not quad corrections are
on. The bbox top ("doctop") is used for objects that do not carry it.
"""
return obj.get("line_top", obj["doctop"])


def _line_x0(obj):
"""Left edge used to group rotated (non-upright) chars and words into lines:
the metric-derived "line_x0" stored by make_chars() for vertical lines, else
the bbox left edge ("x0")."""
return obj.get("line_x0", obj["x0"])


def _line_x1(obj):
"""Right edge used to order the lines of a rotated cell, same source."""
return obj.get("line_x1", obj["x1"])


class WordExtractor:
def __init__(
self,
Expand Down Expand Up @@ -735,6 +759,9 @@ def __init__(

def merge_chars(self, ordered_chars: list):
x0, top, x1, bottom = objects_to_bbox(ordered_chars)
line_tops = [c["line_top"] for c in ordered_chars if "line_top" in c]
line_x0s = [c["line_x0"] for c in ordered_chars if "line_x0" in c]
line_x1s = [c["line_x1"] for c in ordered_chars if "line_x1" in c]
doctop_adj = ordered_chars[0]["doctop"] - ordered_chars[0]["top"]
upright = ordered_chars[0]["upright"]
direction = 1 if (self.horizontal_ltr if upright else self.vertical_ttb) else -1
Expand Down Expand Up @@ -768,6 +795,12 @@ def merge_chars(self, ordered_chars: list):
for key in self.extra_attrs:
word[key] = ordered_chars[0][key]

if line_tops: # like the word top, the smallest of its chars
word["line_top"] = min(line_tops)
if line_x0s: # like the word's x0 and x1
word["line_x0"] = min(line_x0s)
word["line_x1"] = max(line_x1s)

return word

def char_begins_new_word(
Expand Down Expand Up @@ -812,8 +845,8 @@ def char_begins_new_word(
if curr_char["upright"]:
x = self.x_tolerance
y = self.y_tolerance
ay = prev_char["top"]
cy = curr_char["top"]
ay = _line_top(prev_char)
cy = _line_top(curr_char)
if self.horizontal_ltr:
ax = prev_char["x0"]
bx = prev_char["x1"]
Expand All @@ -826,8 +859,8 @@ def char_begins_new_word(
else:
x = self.y_tolerance
y = self.x_tolerance
ay = prev_char["x0"]
cy = curr_char["x0"]
ay = _line_x0(prev_char)
cy = _line_x0(curr_char)
if self.vertical_ttb:
ax = prev_char["top"]
bx = prev_char["bottom"]
Expand Down Expand Up @@ -882,11 +915,11 @@ def upright_key(x) -> int:

for upright_cluster in cluster_objects(list(chars), upright_key, 0):
upright = upright_cluster[0]["upright"]
cluster_key = "doctop" if upright else "x0"
cluster_key = _line_top if upright else _line_x0

# Cluster by line
subclusters = cluster_objects(
upright_cluster, itemgetter(cluster_key), self.y_tolerance
upright_cluster, cluster_key, self.y_tolerance
)

for sc in subclusters:
Expand Down Expand Up @@ -959,13 +992,13 @@ def extract_text(chars: list, **kwargs) -> str:
rotation = 0

if rotation == 90:
words.sort(key=lambda w: (w["x1"], -w["top"]))
words.sort(key=lambda w: (_line_x1(w), -w["top"]))
lines = " ".join([w["text"] for w in words])
elif rotation == 270:
words.sort(key=lambda w: (-w["x1"], w["top"]))
words.sort(key=lambda w: (-_line_x1(w), w["top"]))
lines = " ".join([w["text"] for w in words])
else:
lines = cluster_objects(words, itemgetter("doctop"), y_tolerance)
lines = cluster_objects(words, _line_top, y_tolerance)
lines = "\n".join(" ".join(word["text"] for word in line) for line in lines)
if rotation == 180: # needs extra treatment
lines = "".join([(c if c != "\n" else " ") for c in reversed(lines)])
Expand Down Expand Up @@ -2418,6 +2451,21 @@ def make_chars(page, clip=None):
for span in sorted(line["spans"], key=lambda s: s["bbox"][0]):
fontname = span["font"]
fontsize = span["size"]
# Line top relative to the baseline: the normalised ascender
# times the font size, which is what the quad correction with
# small glyph heights uses for the top of every char box.
# With quad corrections off each char keeps its exact glyph
# box instead, so a "." starts far below the digits around
# it; grouping by this value keeps such chars on their line.
# Vertical lines (page rotated by 90 or 270 degrees) stack along
# x; for them the two box edges along x are derived the same way
# ("line_x0", "line_x1"). The normalised ascender and descender
# add up to the font size, so the descender side is the rest.
asc = span.get("ascender", 0.8)
dsc = span.get("descender", -0.2)
if asc < 1e-3: # glyphless font, same fallback as the quad correction
asc, dsc = 0.9, -0.1
line_ascent = fontsize * (asc / (asc - dsc) if asc - dsc > 0 else 0.8)
span_bold = bool(
span["flags"] & pymupdf.TEXT_FONT_BOLD or span["char_flags"] & 8
)
Expand All @@ -2433,6 +2481,7 @@ def make_chars(page, clip=None):
"adv": bbox.x1 - bbox.x0 if upright else bbox.y1 - bbox.y0,
"bottom": bbox.y1,
"doctop": bbox.y0 + doctop_base,
"line_top": char["origin"][1] - line_ascent + doctop_base,
"fontname": fontname,
"height": bbox.y1 - bbox.y0,
"matrix": tuple(matrix),
Expand All @@ -2454,6 +2503,14 @@ def make_chars(page, clip=None):
"y0": bbox_ctm.y0,
"y1": bbox_ctm.y1,
}
if not upright and ldir[0] == 0:
ox = char["origin"][0]
if ldir[1] > 0: # glyph top points to +x
char_dict["line_x0"] = ox - (fontsize - line_ascent)
char_dict["line_x1"] = ox + line_ascent
else: # glyph top points to -x
char_dict["line_x0"] = ox - line_ascent
char_dict["line_x1"] = ox + (fontsize - line_ascent)
chars.append(char_dict)
return TEXTPAGE

Expand Down
168 changes: 168 additions & 0 deletions tests/test_5198.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Table.extract() keeps "0.74" on one line whatever the quad-corrections toggle says.

Issue #5198: with quad corrections off, which is the state after importing
pymupdf4llm and always the state on the layout path, every character keeps its
exact glyph box. The "." of "0.74" then starts about 7 pt below the digits and
extract_text() grouped it onto a line of its own, so the cell read "0 74\\n.".
Spaces between words were lost the same way when the space character landed in
another line cluster. Cell text is now grouped by the top of the text line
derived from each char's baseline and font metrics, which is the same for every
char on a line in both toggle states.
"""
import pymupdf
import pytest
from pymupdf.table import extract_text, extract_words

GRID = [
["P for interaction", "Cooking fuel type"],
["0.74", "12.5 mg"],
]


def _bordered_grid(page, bbox, texts, fontsize=11):
x0, y0, x1, y1 = bbox
rows, cols = len(texts), len(texts[0])
rh, cw = (y1 - y0) / rows, (x1 - x0) / cols
for r in range(rows + 1):
page.draw_line((x0, y0 + r * rh), (x1, y0 + r * rh))
for c in range(cols + 1):
page.draw_line((x0 + c * cw, y0), (x0 + c * cw, y1))
for r, values in enumerate(texts):
for c, value in enumerate(values):
page.insert_text(
(x0 + c * cw + 8, y0 + (r + 1) * rh - 12), value, fontsize=fontsize
)


def _rotated(expected, rotation):
for _ in range(rotation // 90):
expected = [list(row) for row in zip(*reversed(expected))]
return expected


class _quad_corrections_off:
"""Set the toggle as an import of pymupdf4llm does, restore afterwards."""

def __init__(self, off):
self.off = off

def __enter__(self):
self.old = pymupdf.TOOLS.unset_quad_corrections()
pymupdf.TOOLS.unset_quad_corrections(self.off)

def __exit__(self, *exc):
pymupdf.TOOLS.unset_quad_corrections(self.old)


# --------------------------------------------------------------------------
# Unit tests on the word extractor with hand-made char dicts (no PDF, no toggle)
# --------------------------------------------------------------------------
def _char(text, x0, top, bottom, line_top=None, width=6):
d = {
"text": text,
"x0": x0,
"x1": x0 + width,
"top": top,
"bottom": bottom,
"doctop": top,
"upright": True,
"matrix": (1, 0, 0, 1, x0, bottom),
}
if line_top is not None:
d["line_top"] = line_top
return d


def test_line_top_groups_a_low_glyph_with_its_line():
"""Glyph-tight boxes: the "." starts 6.5 pt below the digits. Grouped by
the shared line top it stays in the number; grouped by the glyph top (the
fallback for chars without line_top) it does not."""
tops = {"0": 100, ".": 106.5, "7": 100, "4": 100}
with_line_top = [
_char(t, 10 + 6 * i, tops[t], 108, line_top=100) for i, t in enumerate("0.74")
]
assert extract_text(with_line_top) == "0.74"
legacy = [_char(t, 10 + 6 * i, tops[t], 108) for i, t in enumerate("0.74")]
assert extract_text(legacy) == "0 74\n."


def test_word_line_top_is_its_smallest_char_line_top():
"""With corrections on every char's line_top equals its box top, and a
word's line top must then equal its bbox top (the smallest char top) so
that line clustering of words is unchanged: a word mixing a tall first
char with lower ones still shares the line of a word at the tall top."""
word = [_char(t, 10 + 6 * i, top, top + 8, line_top=top)
for i, (t, top) in enumerate(zip("ABCCC", (100, 102, 104, 104, 104)))]
other = [_char("X", 60, 100, 108, line_top=100)]
words = extract_words(word + other)
assert [w["text"] for w in words] == ["ABCCC", "X"]
assert words[0]["line_top"] == 100 == words[0]["top"]
assert extract_text(word + other) == "ABCCC X"


# --------------------------------------------------------------------------
# Integration tests through find_tables()
# --------------------------------------------------------------------------
@pytest.mark.parametrize("quad_off", (False, True))
@pytest.mark.parametrize("rotation", (0, 90, 180, 270))
def test_decimal_point_line_based(rotation, quad_off):
with _quad_corrections_off(quad_off), pymupdf.open() as doc:
page = doc.new_page()
_bordered_grid(page, (72, 72, 472, 152), GRID)
page.set_rotation(rotation)
tables = page.find_tables(use_layout=False).tables
assert len(tables) == 1
assert tables[0].extract() == _rotated(GRID, rotation)
# find_tables() leaves the toggle as it found it
assert pymupdf.TOOLS.unset_quad_corrections() == quad_off


@pytest.mark.parametrize("quad_off", (False, True))
def test_decimal_point_layout_path(quad_off):
if not pymupdf._layout_available():
pytest.skip("pymupdf-layout not installed")
with _quad_corrections_off(quad_off), pymupdf.open() as doc:
page = doc.new_page()
_bordered_grid(page, (72, 72, 472, 152), GRID)
tables = page.find_tables().tables
assert len(tables) == 1
# The layout path may add an empty column (a separate issue); each
# row's non-empty cells must still read as written.
rows = [[c for c in row if c] for row in tables[0].extract()]
assert rows == GRID


@pytest.mark.parametrize("quad_off", (False, True))
def test_superscript_stays_on_its_line(quad_off):
"""A raised, smaller footnote marker belongs to the line it annotates.

Grouping by the baseline alone would put "a" (raised 3.5 pt) on a line of
its own; the font-metric line top keeps it with "Total", as the corrected
boxes did.
"""
with _quad_corrections_off(quad_off), pymupdf.open() as doc:
page = doc.new_page()
x0, y0, x1, y1, xm = 72, 380, 372, 420, 222
for p1, p2 in (((x0, y0), (x1, y0)), ((x0, y1), (x1, y1)),
((x0, y0), (x0, y1)), ((xm, y0), (xm, y1)), ((x1, y0), (x1, y1))):
page.draw_line(p1, p2)
page.insert_text((80, 405), "Total", fontsize=11)
page.insert_text((80 + pymupdf.get_text_length("Total", fontsize=11) + 1, 401.5), "a", fontsize=7)
page.insert_text((230, 405), "12.5", fontsize=11)
page.insert_text((230 + pymupdf.get_text_length("12.5", fontsize=11) + 1, 401.5), "*", fontsize=7)
tables = page.find_tables(use_layout=False).tables
assert len(tables) == 1
assert tables[0].extract() == [["Totala", "12.5*"]]


@pytest.mark.parametrize("quad_off", (False, True))
def test_spaces_between_words(quad_off):
"""Small text: word gaps are under the 3 pt x tolerance, so the space
characters themselves must stay on the line to separate the words."""
texts = [["Class A/B Overcollateralization", "Event of Default"]]
with _quad_corrections_off(quad_off), pymupdf.open() as doc:
page = doc.new_page()
_bordered_grid(page, (72, 72, 472, 100), texts, fontsize=8)
tables = page.find_tables(use_layout=False).tables
assert len(tables) == 1
assert tables[0].extract() == texts
Loading
Loading