Repository: burnash/gspread

Issue:
cell.expand('table') feature requests
In the past, I used to use [xlwings](https://docs.xlwings.org/en/latest/index.html) library for Excel. In their API they have a very handy way of selecting "tables"  starting for a specific cell. 

you could use it, for example, to get data from a series of sheets that have table with variable size but always start from the cell `"A1"`. so you "expand" the selection similar to what you do when do `cmd+left_arrow`, `cmd+down_arrow` on the worksheet, 

The usage is better explained in the documentation:
[`range.expand('table')` ](https://docs.xlwings.org/en/latest/api/range.html#xlwings.Range.expand)

this is a snippet of the usage, expand have one of three arguments `"table", "right","down"`. 
```python
import xlwings as xw
wb = xw.Book()
sheet1 = wb.sheets[0]
sheet1.range('A1').value = [[None, 1], [2, 3]]
sheet1.range('A1').expand().address
sheet1.range('A1').expand('table').addres
```

I implemented the `"table"` version for myself just because I found it very handy. 
```python
    def expand_table(worksheet: gspread.worksheet.Worksheet, cell: gspread.Cell) -> str:
        """
        Expands a cell range in gspread based on non-null adjacent cells, similar to
        xlwings.Range('A1').expand('table').
        """

        if not cell.value:
            raise ValueError(f"Cell at {cell} is empty.")
        
        row_values = worksheet.row_values(cell.row)
        row_values = row_values[row_values.index(cell.value):]
        row_values = row_values[:row_values.index('')] if '' in row_values else row_values
        right_boundary_col = cell.col + len(row_values) -1
        
        column_values = worksheet.col_values(right_boundary_col)
        column_values = column_values[column_values.index(worksheet.cell(row=cell.row,col=right_boundary_col).value):]
        column_values = column_values[:column_values.index('')] if '' in column_values else column_values
        bottom_boundary_row = cell.row + len(column_values) -1

        up_a1 = gspread.utils.rowcol_to_a1(cell.row,cell.col)
        low_a1 = gspread.utils.rowcol_to_a1(bottom_boundary_row,right_boundary_col )

        return f'{up_a1}:{low_a1}' 

```
 maybe there's a way of doing this using `gspread`, but I couldn't find it. 
 
Thanks in advance! 




Discussion:
lavigne958: > this is a nice self-contained change. good work here :)
> 
> I think we need more tests, as I foresee a lot of edge cases, and it would be good to catch those before the feature is in gspread.

I thought about it and I thought:
we need to put limits to what we want to handle here:
Do we want to handle empty cells in the middle of the table ?
Do we want to handle cases when the given table is not square ? 
 
> 
> For example, what is the expectation for expanding this table from the top left?
> 
> 1	1	-	1
> 1	1	1	1
> -	-	1	-
> 1	1	1	1

I would expect something like:
- assuming the first row in the above example is data and part of the table, top left 1 is A1 coordinate:
| 1 | 1 |
| 1 | 1 |

- assuming the first row are headers we don't take them:
| 1 | 1 |


> This is not clear to me. Also the internal (private) `utils` expanding functions are unclear what they do.
> 
> As well, we should have a `utils` test _and_ a `worksheet` test. Currently there is only `utils`
so far the method in `Worksheet` class does nothing particular, but I'll add test just in case. 


alifeee: > I thought about it and I thought:
> we need to put limits to what we want to handle here:
> Do we want to handle empty cells in the middle of the table ?
> Do we want to handle cases when the given table is not square ?

I think a reasonable implementation is "the same as what happens when you CTRL+RIGHT and CTRL+DOWN", i.e., to stop just before empty cells.

As for whether that is what other people will expect... that's another question... if people think it should work a certain way, they may think there is a bug... which is why very clear docstrings are good

> assuming the first row are headers we don't take them:
> | 1 | 1 |

wait, huh? why are we ignoring the headers? the function should just return a `List[List]]` with all items in the table starting from the called cell?

I will look back at the code now :)
alifeee: I have added some new tests. In particular, they are "what should happen" tests. They are:

### 1 - missing header item

```python
        values = [
            ["A1", "", "C1", ""],
            ["A2", "B2", "C2", ""],
            ["A3", "B3", "C3", ""],
            ["", "", "", ""],
        ]
        expected_table = [
            ["A1"],
            ["A2"],
            ["A3"],
        ]
```

### 2 - missing initial cell

```python
        values = [
            ["", "B1", "C1", ""],
            ["A2", "B2", "C2", ""],
            ["A3", "B3", "C3", ""],
            ["", "", "", ""],
        ]
        expected_table = [
            ["", "B1", "C1"],
            ["A2", "B2", "C2"],
            ["A3", "B3", "C3"],
        ]
```

### 3 - missing first column item

```python
        values = [
            ["A1", "B1", "C1", ""],
            ["", "B2", "C2", ""],
            ["A3", "B3", "C3", ""],
            ["", "", "", ""],
        ]
        expected_table = [
            ["A1", "B1", "C1"],
            ["", "B2", "C2"],
            ["A3", "B3", "C3"],
        ]
```

### 4 -  missing last column item

```python
        values = [
            ["A1", "B1", "C1", ""],
            ["A2", "B2", "", ""],
            ["A3", "B3", "C3", ""],
            ["", "", "", ""],
        ]
        expected_table = [
            ["A1", "B1", "C1"],
        ]
```

## Conc.

Are these what you expect? Personally I might want case 4 to end up in the entire table, as I reckon I am more likely to have a ***full*** first column, and empty gaps in the final column.

However, if I use the `CTRL+RIGHT` and `CTRL+DOWN` shortcuts in most spreadsheet software, then the tables above are the same. So I am happy with the behaviour above.

I am ready to merge this 👍. However, we should also add it to the list of examples and documentation somewhere.
lavigne958: thank you for the extra tests ! :star: 

I am a bit confused with the tests and what I see when I try it online using keyboard controls :disappointed: 

1. in this case, I agree with you, when I run the same data set with `CTRL+RIGHT` the cell jumps to `C1`... which is not expected at all.... I believe here we should keep our behavior it makes more sense to me according to our documentation and what I would expect from the function
2. I disagree, the first cell is empty, then the array stops here, and returns an empty array :roll_eyes:  I checked online with keyboard controls and for some reasons it jumps to celle `B1` :facepalm: then when you go down it goes to `B3` (as expected)... I don't know what choice is the best here :disappointed: 
3. I completely agree with it, as expected, like the documentation says: we can't scan for empty cells in the middle of the table.
4. I completely agree with it, though online when I try, it get to `C1`, nice, then I use `CTRL+DOWN` then it jumps to `C3` :facepalm: I believe our example is more logic and provides the expected result. 

so for 1., 3. and 4. I agree with your tests and the result is what I would expect.
what is left is case 2., do you think if we start on a blank cell we should start looking right/down for the next cell if it has a value or look right/down for the first non empty cell and start from here ? :thinking:  
alifeee: I have made this sheet

https://docs.google.com/spreadsheets/d/1huMwgagFCGTMVBf5mONk_W74-qHZUnpASHuVHnU6Hsk/edit?usp=sharing

![image](https://github.com/burnash/gspread/assets/13833017/4675d614-95cf-43fd-992f-064d20933f4d)

I think maybe we do not "do the same as CTRL+arrow keys does", as this is confusing. I think we find another rule to follow (perhaps "the same as what xlwings does")
lavigne958: Hey thanks for the working sample ! I completely agree with you some behaviors are completely unexpected :disappointed: 

To me we should go for what feels logical to us, which seems to be what is described in the `xlwing` library:
- going right until first empty cell is found
- going then down until first empty cell is found.
- ignore empty starting cell (like you mentioned)
- table direction should be: _down then right_ as mention in their documentation here: [expand parameters](https://docs.xlwings.org/en/latest/api/range.html#id14)

does that sounds good to you ? :thinking: 
after all we can chose the implementation we like as well, regardless of what the Spreadsheet UI in a web browser does :laughing: 
alifeee: I believe my preference is to "find a table" by "complete row 1 and column 1"

so, to look at these `h` columns

| h | h | h | h |
| --- | --- |  ---  | --- |
| h | - | - | - |
| h | - | - | - |
| h | - | - | - |

...and if they are empty, terminate the table there.

I have updated the tests to do this (please have a look at the tests to see if you agree). What do you think?
lavigne958: > I believe my preference is to "find a table" by "complete row 1 and column 1"
> 
> so, to look at these `h` columns
> 
> h	h	h	h
> h	-	-	-
> h	-	-	-
> h	-	-	-
> ...and if they are empty, terminate the table there.
> 
> I have updated the tests to do this (please have a look at the tests to see if you agree). What do you think?

I agree with this, the only detail would be: look down first then right. 
to follow what xlwings does. it's completely arbitrary but it makes us ISO with the xlwings behavior. 
lavigne958: I did quite a big rework on the iteration functions.

all tests now pass, though the tests already here needed to be adjusted: 
- we now look down first
- then right
- so the expected result changes.

it works as expected, like xlwings. 

alifeee: why does xlwings use the left column and *bottom* row?

it makes more sense to me that one would desire the *top* row? 
lavigne958: I know, that's what I did first, but we agreed on making it ISO with xlwings.

I don't mind changing it, I just want us to agree on one implementation. 
alifeee: I don't think that's how xlwings works. I think xlwings uses the top row and left column.

See here for their implementation: https://github.com/xlwings/xlwings/blob/a159cc9cb7ef5f168ad0d902934404bce4a76386/xlwings/expansion.py#L31-L52

(I can't parse it with my mind fully but it looks to use the top row, not the bottom)

see also this comment: https://github.com/xlwings/xlwings/issues/557#issuecomment-242794085 
lavigne958: > I don't think that's how xlwings works. I think xlwings uses the top row and left column.
> 
> See here for their implementation: https://github.com/xlwings/xlwings/blob/a159cc9cb7ef5f168ad0d902934404bce4a76386/xlwings/expansion.py#L31-L52
> 
> (I can't parse it with my mind fully but it looks to use the top row, not the bottom)
> 
> see also this comment: [xlwings/xlwings#557 (comment)](https://github.com/xlwings/xlwings/issues/557#issuecomment-242794085)

you're right ! 
same I can't parse the code with my mind, but I understand the same thing and it makes sense. to start from the given cell, go right until empty cell, from starting cell again, go down until empty cell.

this provides the right and down boundaries then the rest is just to extract the matrix from here.

I'll update the code, expect potentially a big code change/refactoring then ! 
lavigne958: ## Update

- reworked the way we look for table, look right from starting cell, then look down **from** starting cell
- updated the tests suite to reflect the behavior
  - empty starting cell will return an empty list `[]`
  - empty cell on the way right or down will stop the search
  - empty cell in the **middle** of the matrix will be ignored
  - in the assert functions in the test, iterate over the expected result and expect the values to match
    - I notice tests passing when it should not because we iterate over the result and it's empty so we don't compare anything
- updated the doc a bit
- added extra checks on the size of the matrix and the starting cell coordinates in order to raise Exception if starting cell is outside the range of the given matrix.

I hope this covers any wrong inputs, the expected behavior from all (most?) users.
alifeee: this looks all good to me!

only final thing is that I would expect a table like

| | 1 | 2 | 3 |
| --- | --- | --- | --- |
| a | 0.342 | 0.145 | 0.166 |
| b | 0.982 | 0.576 | 0.600 |
| c | 0.340 | 0.285 | 0.585 |

to expand properly, whereas it seems you suggest with

> empty starting cell will return an empty list []

that it would return as an empty list
lavigne958: Yes to me this function will return the table starting from the given coordinates. The starting cell is part of the resulting table. so if the starting cell is indeed empty then the table will be empty.

So far that seems to be the last key point where we can't find seems to be best. Should we just put it as an option like "ignore empty starting cell" ? Of true then we follow your choice of false we follow mine. This way the user decides the behavior.

What do you think?
alifeee: apologies for the delay

while I think a special case for a blank row 0 column 0 would be nice (e.g., for a labelled adjacency matrix), it is probably not worth the complication (e.g., if the row 0 column 0 is empty, but also row 1 column 0 and row 0 column 1 are empty, it should return `[]` not `[[""]]`)

Thus, I think we return `[]` as you say. If people want their table to be parsed, they should fill the first cell with e.g., `"-"`, or only call the function on the table contents, and use `get_row` and `get_column` (e.g.,) to get the headings.

However, there should be a test to validate this behaviour.

After that, I think this is ready to merge!
lavigne958: I added a new commit with a test to validate the top left cell empty scenario.
In case we find a top left empty cell we return an empty list `[]`.
Is this the test you referred to in you example ? if not let me know I'll add extra tests if need be.

I added an extra test too in the basic test for find_table to check when we reach the end of the list and the end of the matrix. just in case.
alifeee: this is good with me!

with the amount of work spent on a new feature... let's put reference to it in all the relevant places in the documentation :) 
lavigne958: > this is good with me!
> 
> with the amount of work spent on a new feature... let's put reference to it in all the relevant places in the documentation :)

great call, it's done :heavy_check_mark: 
I tried to make it minimal with all the important details.

Patch:
diff --git a/docs/user-guide.rst b/docs/user-guide.rst
index cc45996..50b9097 100644
--- a/docs/user-guide.rst
+++ b/docs/user-guide.rst
@@ -350,7 +350,73 @@ Check out the api docs for `DataValidationRule`_ and `CondtionType`_ for more de
 
 .. _CondtionType: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ConditionType
 
-.. _DataValidationRule: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#DataValidationRule 
+.. _DataValidationRule: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#DataValidationRule
+
+Extract table
+~~~~~~~~~~~~~
+
+Gspread provides a function to extract a data table.
+A data table is defined as a rectangular table that stops either on the **first empty** cell or
+the **enge of the sheet**.
+
+You can extract table from any address by providing the top left corner of the desired table.
+
+Gspread provides 3 directions for searching the end of the table:
+
+   * :attr:`~gspread.utils.TableDirection.right`: extract a single row searching on the right of the starting cell
+   * :attr:`~gspread.utils.TableDirection.down`: extract a single column searching on the bottom of the starting cell
+   * :attr:`~gspread.utils.TableDirection.table`: extract a rectangular table by first searching right from starting cell,
+     then searching down from starting cell.
+
+      .. note::
+
+        Gspread will not look for empty cell inside the table. it only look at the top row and first column.
+
+Example extracting a table from the below sample sheet:
+
+.. list-table:: Find table
+   :header-rows: 1
+
+   * - ID
+     - Name
+     - Universe
+     - Super power
+   * - 1
+     - Batman
+     - DC
+     - Very rich
+   * - 2
+     - DeadPool
+     - Marvel
+     - self healing
+   * - 3
+     - Superman
+     - DC
+     - super human
+   * -
+     - \-
+     - \-
+     - \-
+   * - 5
+     - Lavigne958
+     -
+     - maintains Gspread
+   * - 6
+     - Alifee
+     -
+     - maintains Gspread
+
+Using the below code will result in rows 2 to 4:
+
+.. code:: python
+
+   worksheet.expand("A2")
+
+   [
+      ["Batman", "DC", "Very rich"],
+      ["DeadPool", "Marvel", "self healing"],
+      ["Superman", "DC", "super human"],
+   ]
 
 
 
diff --git a/gspread/utils.py b/gspread/utils.py
index 6f3fede..6daac86 100644
--- a/gspread/utils.py
+++ b/gspread/utils.py
@@ -168,6 +168,12 @@ class ValidationConditionType(StrEnum):
     filter_expression = "FILTER_EXPRESSION"
 
 
+class TableDirection(StrEnum):
+    table = "TABLE"
+    down = "DOWN"
+    right = "RIGHT"
+
+
 def convert_credentials(credentials: Credentials) -> Credentials:
     module = credentials.__module__
     cls = credentials.__class__.__name__
@@ -979,6 +985,129 @@ def to_records(
     return [dict(zip(headers, row)) for row in values]
 
 
+def _expand_right(values: List[List[str]], start: int, end: int, row: int) -> int:
+    """This is a private function, returning the column index of the last non empty cell
+    on the given row.
+
+    Search starts from ``start`` index column.
+    Search ends on ``end`` index column.
+    Searches only in the row pointed by ``row``.
+    """
+    try:
+        return values[row].index("", start, end) - 1
+    except ValueError:
+        return end
+
+
+def _expand_bottom(values: List[List[str]], start: int, end: int, col: int) -> int:
+    """This is a private function, returning the row index of the last non empty cell
+    on the given column.
+
+    Search starts from ``start`` index row.
+    Search ends on ``end`` index row.
+    Searches only in the column pointed by ``col``.
+    """
+    for rows in range(start, end):
+        # in case we try to look further than last row
+        if rows >= len(values):
+            return len(values) - 1
+
+        # check if cell is empty (or the row => empty cell)
+        if col >= len(values[rows]) or values[rows][col] == "":
+            return rows - 1
+
+    return end - 1
+
+
+def find_table(
+    values: List[List[str]],
+    start_range: str,
+    direction: TableDirection = TableDirection.table,
+) -> List[List[str]]:
+    """Expands a list of values based on non-null adjacent cells.
+
+    Expand can be done in 3 directions defined in :class:`~gspread.utils.TableDirection`
+
+        * ``TableDirection.right``: expands right until the first empty cell
+        * ``TableDirection.down``: expands down until the first empty cell
+        * ``TableDirection.table``: expands right until the first empty cell and down until first empty cell
+
+    In case of empty result an empty list is restuned.
+
+    When the given ``start_range`` is outside the given matrix of values the exception
+    :class:`~gspread.exceptions.InvalidInputValue` is raised.
+
+    Example::
+
+        values = [
+            ['', '',   '',   '', ''  ],
+            ['', 'B2', 'C2', '', 'E2'],
+            ['', 'B3', 'C3', '', 'E3'],
+            ['', ''  , ''  , '', 'E4'],
+        ]
+        >>> utils.find_table(TableDirection.table, 'B2')
+        [
+            ['B2', 'C2'],
+            ['B3', 'C3'],
+        ]
+
+
+    .. note::
+
+       the ``TableDirection.table`` will look right from starting cell then look down from starting cell.
+       It will not check cells located inside the table. This could lead to
+       potential empty values located in the middle of the table.
+
+    .. warning::
+
+       Given values must be padded with `''` empty values.
+
+    :param list[list] values: values where to find the table.
+    :param gspread.utils.TableDirection direction: the expand direction.
+    :param str start_range: the starting cell range.
+    :rtype list(list): the resulting matrix
+    """
+    row, col = a1_to_rowcol(start_range)
+
+    # a1_to_rowcol returns coordinates starting form 1
+    row -= 1
+    col -= 1
+
+    if row >= len(values):
+        raise InvalidInputValue(
+            "given row for start_range is outside given values: start range row ({}) >= rows in values {}".format(
+                row, len(values)
+            )
+        )
+
+    if col >= len(values[row]):
+        raise InvalidInputValue(
+            "given column for start_range is outside given values: start range column ({}) >= columns in values {}".format(
+                col, len(values[row])
+            )
+        )
+
+    if direction == TableDirection.down:
+        rightMost = col
+        bottomMost = _expand_bottom(values, row, len(values), col)
+
+    if direction == TableDirection.right:
+        bottomMost = row
+        rightMost = _expand_right(values, col, len(values[row]), row)
+
+    if direction == TableDirection.table:
+        rightMost = _expand_right(values, col, len(values[row]), row)
+        bottomMost = _expand_bottom(values, row, len(values), col)
+
+    result = []
+
+    # build resulting array
+    for rows in values[row : bottomMost + 1]:
+        result.append(rows[col : rightMost + 1])
+
+    return result
+
+
 # SHOULD NOT BE NEEDED UNTIL NEXT MAJOR VERSION
 # DEPRECATION_WARNING_TEMPLATE = (
 #     "[Deprecated][in version {v_deprecated}]: {msg_deprecated}"
diff --git a/gspread/worksheet.py b/gspread/worksheet.py
index 463a727..109c44d 100644
--- a/gspread/worksheet.py
+++ b/gspread/worksheet.py
@@ -41,6 +41,7 @@ from .utils import (
     PasteOrientation,
     PasteType,
     T,
+    TableDirection,
     ValidationConditionType,
     ValueInputOption,
     ValueRenderOption,
@@ -53,6 +54,7 @@ from .utils import (
     convert_colors_to_hex_value,
     convert_hex_to_colors_dict,
     fill_gaps,
+    find_table,
     finditem,
     get_a1_from_absolute_range,
     is_full_a1_notation,
@@ -3336,3 +3338,56 @@ class Worksheet:
         }
 
         return self.client.batch_update(self.spreadsheet_id, body)
+
+    def expand(
+        self,
+        top_left_range_name: str = "A1",
+        direction: TableDirection = TableDirection.table,
+    ) -> List[List[str]]:
+        """Expands a cell range based on non-null adjacent cells.
+
+        Expand can be done in 3 directions defined in :class:`~gspread.utils.TableDirection`
+
+        * ``TableDirection.right``: expands right until the first empty cell
+        * ``TableDirection.down``: expands down until the first empty cell
+        * ``TableDirection.table``: expands right until the first empty cell and down until the first empty cell
+
+        In case of empty result an empty list is restuned.
+
+        When the given ``start_range`` is outside the given matrix of values the exception
+        :class:`~gspread.exceptions.InvalidInputValue` is raised.
+
+        Example::
+
+            values = [
+                ['', '',   '',   '', ''  ],
+                ['', 'B2', 'C2', '', 'E2'],
+                ['', 'B3', 'C3', '', 'E3'],
+                ['', ''  , ''  , '', 'E4'],
+            ]
+            >>> utils.find_table(TableDirection.table, 'B2')
+            [
+                ['B2', 'C2'],
+                ['B3', 'C3'],
+            ]
+
+
+        .. note::
+
+            the ``TableDirection.table`` will look right from starting cell then look down from starting cell.
+            It will not check cells located inside the table. This could lead to
+            potential empty values located in the middle of the table.
+
+        .. note::
+
+            when it is necessary to use non-default options for :meth:`~gspread.worksheet.Worksheet.get`,
+            please get the data first using desired options then use the function
+            :func:`gspread.utils.find_table` to extract the desired table.
+
+        :param str top_left_range_name: the top left corner of the table to expand.
+        :param gspread.utils.TableDirection direction: the expand direction
+        :rtype list(list): the resulting matrix
+        """
+
+        values = self.get(pad_values=True)
+        return find_table(values, top_left_range_name, direction)


Test patch:
diff --git a/tests/utils_test.py b/tests/utils_test.py
index 187e74e..f64b759 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -503,3 +503,173 @@ class UtilsTest(unittest.TestCase):
                 # given key are unordered
                 # but they must match a value from the given input values
                 self.assertIn(record[key], values[i])
+
+    def test_find_table_simple(self):
+        """Test find table with basic case"""
+        values = [
+            ["A1", "B1", "C1", "D1"],
+            ["", "B2", "C2", "", "E2"],
+            ["", "B3", "C3", "D3", "E3"],
+            ["A4", "", "C4", "D4", "E4"],
+        ]
+
+        table = utils.find_table(
+            values,
+            "B2",
+            utils.TableDirection.table,
+        )
+
+        table_max_row_max_column = utils.find_table(
+            values,
+            "D3",
+            utils.TableDirection.table,
+        )
+        right = utils.find_table(
+            values,
+            "B2",
+            utils.TableDirection.right,
+        )
+        down = utils.find_table(
+            values,
+            "B2",
+            utils.TableDirection.down,
+        )
+        single = utils.find_table(values, "D1", utils.TableDirection.table)
+        no_values = utils.find_table(values, "A2", utils.TableDirection.table)
+
+        table_values = [
+            ["B2", "C2"],
+            ["B3", "C3"],
+        ]
+
+        for rowindex, row in enumerate(table_values):
+            self.assertListEqual(row, table[rowindex])
+
+        table_max_row_max_column_values = [
+            ["D3", "E3"],
+            ["D4", "E4"],
+        ]
+
+        for rowindex, row in enumerate(table_max_row_max_column):
+            self.assertListEqual(row, table_max_row_max_column_values[rowindex])
+
+        right_values = [
+            ["B2", "C2"],
+        ]
+        for rowindex, row in enumerate(right_values):
+            self.assertListEqual(row, right[rowindex])
+
+        bottom_values = [
+            ["B2"],
+            ["B3"],
+        ]
+        for rowindex, row in enumerate(bottom_values):
+            self.assertListEqual(row, down[rowindex])
+
+        self.assertEqual(len(single), 1)
+        self.assertEqual(len(single[0]), 1)
+        self.assertEqual(single[0][0], "D1")
+        self.assertEqual(no_values, [])
+
+    def test_find_table_inner_gap(self):
+        """Test find table with gap in header"""
+        values = [
+            ["A1", "B1", "C1", ""],
+            ["A2", "", "C2", ""],
+            ["A3", "B3", "C3", ""],
+            ["", "", "", ""],
+        ]
+        expected_table = [
+            ["A1", "B1", "C1"],
+            ["A2", "", "C2"],
+            ["A3", "B3", "C3"],
+        ]
+
+        table = utils.find_table(
+            values,
+            "A1",
+            utils.TableDirection.table,
+        )
+
+        for rowindex, row in enumerate(expected_table):
+            self.assertListEqual(row, table[rowindex])
+
+    def test_find_table_first_row_gap(self):
+        """Test find table with first cell empty"""
+        values = [
+            ["A1", "", "C1", ""],
+            ["A2", "B2", "C2", ""],
+            ["A3", "B3", "C3", ""],
+            ["", "", "", ""],
+        ]
+        expected_table = [
+            ["A1"],
+            ["A2"],
+            ["A3"],
+        ]
+
+        table = utils.find_table(
+            values,
+            "A1",
+            utils.TableDirection.table,
+        )
+
+        for rowindex, row in enumerate(expected_table):
+            self.assertListEqual(row, table[rowindex])
+
+    def test_find_table_first_column_gap(self):
+        """Test find table with a gap in first column"""
+        values = [
+            ["A1", "B1", "C1", ""],
+            ["", "B2", "C2", ""],
+            ["A3", "B3", "C3", ""],
+            ["", "", "", ""],
+        ]
+        expected_table = [
+            ["A1", "B1", "C1"],
+        ]
+
+        table = utils.find_table(
+            values,
+            "A1",
+            utils.TableDirection.table,
+        )
+
+        for rowindex, row in enumerate(expected_table):
+            self.assertListEqual(row, table[rowindex])
+
+    def test_find_table_last_column_gap(self):
+        """Test find table with a gap in last column"""
+        values = [
+            ["A1", "B1", "C1", ""],
+            ["A2", "B2", "", ""],
+            ["A3", "B3", "C3", ""],
+            ["", "", "", ""],
+        ]
+        expected_table = [
+            ["A1", "B1", "C1"],
+            ["A2", "B2", ""],
+            ["A3", "B3", "C3"],
+        ]
+
+        table = utils.find_table(
+            values,
+            "A1",
+            utils.TableDirection.table,
+        )
+
+        for rowindex, row in enumerate(expected_table):
+            self.assertListEqual(row, table[rowindex])
+
+    def test_find_table_empty_top_left_corner(self):
+        """Test find table with an empty top left cell and empty adjacent cells"""
+
+        values = [
+            ["", "", "C1", ""],
+            ["", "B2", "C2", ""],
+            ["", "B3", "C3", ""],
+        ]
+
+        table = utils.find_table(values, "A1", utils.TableDirection.table)
+
+        self.assertListEqual(table, [], "resulting table should be empty")