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
20 changes: 20 additions & 0 deletions test_unstructured/partition/utils/test_sorting.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,26 @@ def test_sort_basic_neg_coordinates():
assert sorted_elem_text == "2 1 0"


def test_sort_xycut_rtl_reverses_single_row_order():
# three elements on a single row, left-to-right on the page: '0', '1', '2'.
elements = []
for idx, left in enumerate([0, 20, 40]):
elem = Text(str(idx))
elem.metadata.coordinates = CoordinatesMetadata(
[(left, 0), (left, 10), (left + 10, 10), (left + 10, 0)],
PixelSpace,
)
elements.append(elem)

ltr = sort_page_elements(elements, sort_mode=SORT_MODE_XY_CUT, xy_cut_primary_direction="y")
assert [str(e.text) for e in ltr] == ["0", "1", "2"]

rtl = sort_page_elements(
elements, sort_mode=SORT_MODE_XY_CUT, xy_cut_primary_direction="y", rtl=True
)
assert [str(e.text) for e in rtl] == ["2", "1", "0"]


def test_sort_basic_pos_coordinates():
elements = []
for idx in range(3):
Expand Down
39 changes: 39 additions & 0 deletions test_unstructured/partition/utils/test_xycut.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,45 @@ def test_recursive_xy_cut(recursive_func, expected):
assert res == expected


@pytest.mark.parametrize(
("recursive_func", "expected"),
[
(xycut.recursive_xy_cut, [1, 0, 2]),
(xycut.recursive_xy_cut_swapped, [1, 0, 2]),
],
)
def test_recursive_xy_cut_rtl(recursive_func, expected):
# same fixture as test_recursive_xy_cut: boxes 0 and 1 share a row, box 2 is
# its own row below. With rtl=True, box 1 (rightmost in that row) should come
# before box 0, while the row order itself (0/1 before 2) is unchanged - RTL
# only affects horizontal reading direction, not vertical.
boxes = np.array([[0, 0, 20, 20], [200, 0, 230, 30], [0, 40, 50, 50]])
indices = np.array([0, 1, 2])
res = []
recursive_func(boxes, indices, res, rtl=True)
assert res == expected


@pytest.mark.parametrize(
"recursive_func",
[xycut.recursive_xy_cut, xycut.recursive_xy_cut_swapped],
)
def test_recursive_xy_cut_rtl_reverses_single_row(recursive_func):
# three boxes on a single row, left-to-right on the page: A, B, C.
# LTR reading order is A, B, C (indices 0, 1, 2).
# RTL reading order should be the mirror: C, B, A (indices 2, 1, 0).
boxes = np.array([[0, 0, 10, 10], [20, 0, 30, 10], [40, 0, 50, 10]])
indices = np.array([0, 1, 2])

ltr_res: list = []
recursive_func(boxes, indices, ltr_res)
assert ltr_res == [0, 1, 2]

rtl_res: list = []
recursive_func(boxes, indices, rtl_res, rtl=True)
assert rtl_res == [2, 1, 0]


def test_points_to_bbox():
# Test a valid case
points = [10, 20, 30, 40, 50, 60, 70, 80]
Expand Down
13 changes: 13 additions & 0 deletions unstructured/partition/utils/sorting.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def sort_page_elements(
sort_mode: str = SORT_MODE_XY_CUT,
shrink_factor: float = 0.9,
xy_cut_primary_direction: str = "x",
rtl: bool = False,
) -> list[Element]:
"""
Sorts a list of page elements based on the specified sorting mode.
Expand All @@ -116,6 +117,9 @@ def sort_page_elements(
- SORT_MODE_BASIC: Sorts elements based on their coordinates. Elements without coordinates
will be pushed to the end.
- If an unrecognized sort_mode is provided, the function returns the elements as-is.
- rtl (bool, optional): Only applies to SORT_MODE_XY_CUT. When True, column/block order
within a row is right-to-left instead of left-to-right, matching the reading order of
right-to-left scripts (e.g. Arabic, Hebrew). Default is False.

Returns:
- list[Element]: A list of sorted page elements.
Expand All @@ -130,6 +134,8 @@ def sort_page_elements(
xy_cut_primary_direction,
)

rtl = os.environ.get("UNSTRUCTURED_XY_CUT_RTL", str(rtl)).lower() == "true"

if not page_elements:
return []

Expand Down Expand Up @@ -169,6 +175,7 @@ def _coords_ok(strict_points: bool):
np.asarray(shrunken_bboxes).astype(int),
np.arange(len(shrunken_bboxes)),
res,
rtl=rtl,
)
sorted_page_elements = [page_elements[i] for i in res]
elif sort_mode == SORT_MODE_BASIC:
Expand All @@ -191,6 +198,7 @@ def sort_bboxes_by_xy_cut(
bboxes,
shrink_factor: float = 0.9,
xy_cut_primary_direction: str = "x",
rtl: bool = False,
):
"""Sort bounding boxes using XY-cut algorithm."""

Expand All @@ -207,6 +215,7 @@ def sort_bboxes_by_xy_cut(
np.asarray(shrunken_bboxes).astype(int),
np.arange(len(shrunken_bboxes)),
res,
rtl=rtl,
)
return res

Expand All @@ -216,6 +225,7 @@ def sort_text_regions(
sort_mode: str = SORT_MODE_XY_CUT,
shrink_factor: float = 0.9,
xy_cut_primary_direction: str = "x",
rtl: bool = False,
) -> TextRegions:
"""Sort a list of TextRegion elements based on the specified sorting mode."""

Expand Down Expand Up @@ -250,10 +260,13 @@ def _bboxes_ok(strict_points: bool):
xy_cut_primary_direction,
)

rtl = os.environ.get("UNSTRUCTURED_XY_CUT_RTL", str(rtl)).lower() == "true"

res = sort_bboxes_by_xy_cut(
bboxes=bboxes,
shrink_factor=shrink_factor,
xy_cut_primary_direction=xy_cut_primary_direction,
rtl=rtl,
)
sorted_elements = elements.slice(res)
elif sort_mode == SORT_MODE_BASIC:
Expand Down
24 changes: 19 additions & 5 deletions unstructured/partition/utils/xycut.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,18 @@ def split_projection_profile(arr_values: np.ndarray, min_value: float, min_gap:
return arr_start, arr_end


def recursive_xy_cut(boxes: np.ndarray, indices: np.ndarray, res: List[int]):
def recursive_xy_cut(boxes: np.ndarray, indices: np.ndarray, res: List[int], rtl: bool = False):
"""

Args:
boxes: (N, 4)
indices: during the recursion process, the index of box in the original data
is always represented.
res: save output
rtl: when True, column groups within a row are visited right-to-left instead
of left-to-right, matching the reading order of right-to-left scripts (e.g.
Arabic, Hebrew). Row order (top-to-bottom) is unaffected, since that's the
same for LTR and RTL documents.

"""
# project to the y-axis
Expand Down Expand Up @@ -139,26 +143,34 @@ def recursive_xy_cut(boxes: np.ndarray, indices: np.ndarray, res: List[int]):
arr_x0, arr_x1 = pos_x
if len(arr_x0) == 1:
# x-direction cannot be divided
res.extend(x_sorted_indices_chunk)
res.extend(reversed(x_sorted_indices_chunk) if rtl else x_sorted_indices_chunk)
continue

# can be separated in the x-direction and continue to call recursively
for c0, c1 in zip(arr_x0, arr_x1):
x_groups = zip(arr_x0, arr_x1)
for c0, c1 in reversed(list(x_groups)) if rtl else x_groups:
_indices = (c0 <= x_sorted_boxes_chunk[:, 0]) & (x_sorted_boxes_chunk[:, 0] < c1)
recursive_xy_cut(
x_sorted_boxes_chunk[_indices],
x_sorted_indices_chunk[_indices],
res,
rtl=rtl,
)


def recursive_xy_cut_swapped(boxes: np.ndarray, indices: np.ndarray, res: List[int]):
def recursive_xy_cut_swapped(
boxes: np.ndarray, indices: np.ndarray, res: List[int], rtl: bool = False
):
"""
Args:
boxes: (N, 4) - Numpy array representing bounding boxes with shape (N, 4)
where each row is (left, top, right, bottom)
indices: An array representing indices that correspond to boxes in the original data
res: A list to save the output results
rtl: when True, the x-axis column segments are visited right-to-left instead
of left-to-right, matching the reading order of right-to-left scripts (e.g.
Arabic, Hebrew). The y-axis (top-to-bottom) order within each column is
unaffected, since that's the same for LTR and RTL documents.
"""

# Sort the bounding boxes based on x-coordinates (flipped)
Expand All @@ -177,7 +189,8 @@ def recursive_xy_cut_swapped(boxes: np.ndarray, indices: np.ndarray, res: List[i
arr_x0, arr_x1 = pos_x

# Loop over the segments obtained from the x-axis projection
for c0, c1 in zip(arr_x0, arr_x1):
x_groups = zip(arr_x0, arr_x1)
for c0, c1 in reversed(list(x_groups)) if rtl else x_groups:
# Obtain sub-boxes in the x-axis segment
_indices = (c0 <= x_sorted_boxes[:, 0]) & (x_sorted_boxes[:, 0] < c1)
x_sorted_boxes_chunk = x_sorted_boxes[_indices]
Expand Down Expand Up @@ -209,6 +222,7 @@ def recursive_xy_cut_swapped(boxes: np.ndarray, indices: np.ndarray, res: List[i
y_sorted_boxes_chunk[_indices],
y_sorted_indices_chunk[_indices],
res,
rtl=rtl,
)


Expand Down