diff --git a/src/inscriptis/model/table.py b/src/inscriptis/model/table.py
index cf5a498..a16452a 100644
--- a/src/inscriptis/model/table.py
+++ b/src/inscriptis/model/table.py
@@ -20,6 +20,7 @@ class TableCell(Canvas):
"""
__slots__ = (
+ "_content_height",
"_width",
"align",
"annotation_counter",
@@ -40,6 +41,7 @@ def __init__(self, align: HorizontalAlignment, valign: VerticalAlignment):
self._width = None
self.line_width: list[int] = []
self.vertical_padding = 0
+ self._content_height = 0
def normalize_blocks(self) -> int:
"""Split multi-line blocks into multiple one-line blocks.
@@ -52,7 +54,10 @@ def normalize_blocks(self) -> int:
self.blocks = list(chain(*(line.split("\n") for line in self.blocks)))
if not self.blocks:
self.blocks = [""]
- return len(self.blocks)
+ # Remember the pre-padding row count so annotation mapping can
+ # distinguish content lines from padding lines added by `height.setter`.
+ self._content_height = len(self.blocks)
+ return self._content_height
@property
def height(self) -> int:
@@ -133,14 +138,26 @@ def get_annotations(self, idx: int, row_width: int) -> list[Annotation]:
return result
# the more challenging one - multiple cell lines
- line_break_pos = list(accumulate(self.line_width))
+ #
+ # `self.line_width` is `height`-long after vertical padding was
+ # applied: zero-length entries fill the top (`self.vertical_padding`)
+ # and bottom (for VerticalAlignment.middle) padding slots, while the
+ # remaining `self._content_height` entries hold the original line
+ # widths. Annotation `start` positions reference the *pre-padding*
+ # joined content (one newline between lines), so we must scan only
+ # the content widths to find which content line an annotation falls
+ # on, then offset the destination by the top padding to land on the
+ # correct output line.
+ top_pad = self.vertical_padding
+ content_widths = self.line_width[top_pad : top_pad + self._content_height]
+ line_break_pos = list(accumulate(content_widths))
annotation_lines = [[] for _ in self.blocks]
# assign annotations to the corresponding line
for a in self.annotations:
for no, line_break in enumerate(line_break_pos):
if a.start <= (line_break + no): # consider newline
- annotation_lines[no + self.vertical_padding].append(a)
+ annotation_lines[no + top_pad].append(a)
break
# compute the annotation index based on its line and delta :)
diff --git a/tests/test_table_annotation_vertical_padding.py b/tests/test_table_annotation_vertical_padding.py
new file mode 100644
index 0000000..7af4398
--- /dev/null
+++ b/tests/test_table_annotation_vertical_padding.py
@@ -0,0 +1,140 @@
+#!/usr/bin/env python
+
+"""Regression tests for table cell annotation offsets in the presence of
+vertical padding.
+
+The multi-line ``TableCell.get_annotations`` path previously indexed
+``annotation_lines`` by ``no + self.vertical_padding`` while iterating over
+``line_break_pos`` built from the post-padded ``self.line_width``. That made
+``no`` itself a padded line index, so the additional offset double-counted the
+top padding and either crashed with ``IndexError`` (``valign=bottom``) or
+silently shifted annotations onto padding lines (``valign=middle``).
+
+Each test below forces real content lines (using ``
`` rather than block
+elements that emit margin-empty-lines) so the padding/content distinction is
+exercised cleanly.
+"""
+
+from inscriptis import get_annotated_text
+from inscriptis.model.config import ParserConfig
+
+
+def _annotated(html: str, rules: dict[str, list[str]]) -> dict:
+ return get_annotated_text(html, ParserConfig(annotation_rules=rules))
+
+
+def test_valign_bottom_indexerror_regression():
+ """Reproduces the IndexError raised by inscriptis 2.7.3 when a cell with
+ ``vertical-align: bottom`` is shorter than its row and contains
+ annotations spanning multiple content lines.
+
+ The short cell has 3 content lines vs. 10 in the sibling, forcing
+ ``vertical_padding = 7``. Pre-fix, the second ``li`` annotation matches
+ at padded index 7 and is then written to ``annotation_lines[7 + 7]``,
+ overflowing the 10-entry list.
+ """
+ html = """
+
+
|
+
+
|
+
|
+ x1 x2 x3 + |
+ L1 L2 L3 L4 L5 L6 L7 L8 L9 |
+
|
+ first second third + |
+ L1 L2 L3 L4 L5 L6 |
+
+
|
+
+
|
+