diff --git a/test_unstructured/partition/utils/test_sorting.py b/test_unstructured/partition/utils/test_sorting.py index 85660b1b3b..0d90313120 100644 --- a/test_unstructured/partition/utils/test_sorting.py +++ b/test_unstructured/partition/utils/test_sorting.py @@ -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): diff --git a/test_unstructured/partition/utils/test_xycut.py b/test_unstructured/partition/utils/test_xycut.py index cbe3487b88..3062e54c57 100644 --- a/test_unstructured/partition/utils/test_xycut.py +++ b/test_unstructured/partition/utils/test_xycut.py @@ -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] diff --git a/unstructured/partition/utils/sorting.py b/unstructured/partition/utils/sorting.py index 700f1288a9..b665b16171 100644 --- a/unstructured/partition/utils/sorting.py +++ b/unstructured/partition/utils/sorting.py @@ -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. @@ -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. @@ -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 [] @@ -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: @@ -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.""" @@ -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 @@ -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.""" @@ -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: diff --git a/unstructured/partition/utils/xycut.py b/unstructured/partition/utils/xycut.py index 60804657ca..42e115c537 100644 --- a/unstructured/partition/utils/xycut.py +++ b/unstructured/partition/utils/xycut.py @@ -93,7 +93,7 @@ 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: @@ -101,6 +101,10 @@ def recursive_xy_cut(boxes: np.ndarray, indices: np.ndarray, res: List[int]): 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 @@ -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) @@ -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] @@ -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, )