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
43 changes: 40 additions & 3 deletions docling_core/types/doc/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,22 @@ class PdfTextCell(TextCell):
font_key: str
font_name: str

clip_bbox: Annotated[
BoundingBox | None,
Field(
description=(
"Bounding box of the clip region that was in force when the "
"text was painted (ISO 32000-1, 8.5.4), in the same coordinate "
"origin as `rect`; None when nothing clipped it. `rect` is the "
"full glyph box: the part of it outside this box is not "
"visible on the page. A producer that fits long text into a "
"table cell by clipping it to the cell leaves only this box to "
"say which cell the text belongs to. A word or line carries "
"the box when all of its glyphs share it."
)
),
] = None

from_ocr: Literal[False] = False

@model_validator(mode="before")
Expand All @@ -382,6 +398,26 @@ def update_ltr_property(cls, data: dict) -> dict:
# data["index"] = data["ordering"]
return data

def to_bottom_left_origin(self, page_height: float):
"""Convert the cell's coordinates to use bottom-left origin.

Args:
page_height: The height of the page
"""
super().to_bottom_left_origin(page_height=page_height)
if self.clip_bbox is not None:
self.clip_bbox = self.clip_bbox.to_bottom_left_origin(page_height=page_height)

def to_top_left_origin(self, page_height: float):
"""Convert the cell's coordinates to use top-left origin.

Args:
page_height: The height of the page
"""
super().to_top_left_origin(page_height=page_height)
if self.clip_bbox is not None:
self.clip_bbox = self.clip_bbox.to_top_left_origin(page_height=page_height)


class PdfWidget(OrderedElement):
"""One PDF widget annotation with raw AcroForm field state.
Expand Down Expand Up @@ -784,12 +820,13 @@ def get_cells_in_bbox(
cells = []
for page_cell in self.iterate_cells(cell_unit):
pc = copy.deepcopy(page_cell)
# Bring cell_bbox coord origin to the same as input bbox.coord_origin:
# Bring the cell's boxes (rect, and clip_bbox when the cell is a
# PdfTextCell) to the same coord origin as the input bbox:
if page_cell.rect.coord_origin != bbox.coord_origin:
if bbox.coord_origin == CoordOrigin.TOPLEFT:
pc.rect = pc.rect.to_top_left_origin(self.dimension.height)
pc.to_top_left_origin(self.dimension.height)
elif bbox.coord_origin == CoordOrigin.BOTTOMLEFT:
pc.rect = pc.rect.to_bottom_left_origin(self.dimension.height)
pc.to_bottom_left_origin(self.dimension.height)
cell_bbox = pc.to_bounding_box()
if cell_bbox.intersection_over_self(bbox) > ios:
cells.append(pc)
Expand Down
121 changes: 120 additions & 1 deletion tests/test_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@
import pytest
from pydantic import AnyUrl, ValidationError

from docling_core.types.doc import CoordOrigin, Size
from docling_core.types.doc import BoundingBox, CoordOrigin, Size
from docling_core.types.doc.page import (
BoundingRectangle,
Coord2D,
PdfCellRenderingMode,
PdfDestination,
PdfDestinationKind,
PdfHyperlink,
PdfPageBoundaryType,
PdfPageGeometry,
PdfTableOfContents,
PdfTextCell,
PdfWidget,
SegmentedPdfPage,
TextCellUnit,
)

SQRT_2 = math.sqrt(2)
Expand Down Expand Up @@ -422,3 +428,116 @@ def test_unknown_kind_is_the_documented_fallback(self):
assert dest.kind == PdfDestinationKind.UNKNOWN
assert dest.point is None
assert dest.coord_origin == CoordOrigin.BOTTOMLEFT


def _segmented_pdf_page(page_height: float, **cells) -> SegmentedPdfPage:
page_box = BoundingBox(l=0.0, b=0.0, r=612.0, t=page_height, coord_origin=CoordOrigin.BOTTOMLEFT)
return SegmentedPdfPage(
dimension=PdfPageGeometry(
angle=0.0,
rect=BoundingRectangle.from_bounding_box(page_box),
boundary_type=PdfPageBoundaryType.CROP_BOX,
art_bbox=page_box,
bleed_bbox=page_box,
crop_bbox=page_box,
media_bbox=page_box,
trim_bbox=page_box,
),
char_cells=cells.get("char_cells", []),
word_cells=cells.get("word_cells", []),
textline_cells=cells.get("textline_cells", []),
)


def _pdf_text_cell(**kwargs) -> PdfTextCell:
return PdfTextCell(
rect=BoundingRectangle(
r_x0=125.0,
r_y0=682.0,
r_x1=304.6,
r_y1=682.0,
r_x2=304.6,
r_y2=691.0,
r_x3=125.0,
r_y3=691.0,
coord_origin=CoordOrigin.BOTTOMLEFT,
),
text="level",
orig="level",
rendering_mode=PdfCellRenderingMode.FILL_TEXT,
widget=False,
font_key="/F1",
font_name="Helvetica",
**kwargs,
)


class TestPdfTextCellClipBbox:
"""Text clipped to a table cell carries the clip it was painted under."""

def test_defaults_to_none_and_is_omitted_from_export(self):
cell = _pdf_text_cell()
assert cell.clip_bbox is None
assert "clip_bbox" not in cell.model_dump(mode="json", exclude_none=True)

def test_legacy_payload_still_validates(self):
payload = _pdf_text_cell().model_dump(mode="json")
del payload["clip_bbox"]
assert PdfTextCell.model_validate(payload).clip_bbox is None

def test_round_trips(self):
clip = BoundingBox(l=122.0, b=676.0, r=252.0, t=698.0, coord_origin=CoordOrigin.BOTTOMLEFT)
cell = _pdf_text_cell(clip_bbox=clip)
restored = PdfTextCell.model_validate(cell.model_dump(mode="json"))
assert restored.clip_bbox == clip

def test_origin_conversion_moves_the_clip_with_the_rect(self):
clip = BoundingBox(l=122.0, b=676.0, r=252.0, t=698.0, coord_origin=CoordOrigin.BOTTOMLEFT)
cell = _pdf_text_cell(clip_bbox=clip)

cell.to_top_left_origin(page_height=792.0)
assert cell.rect.coord_origin == CoordOrigin.TOPLEFT
assert cell.clip_bbox is not None
assert cell.clip_bbox.coord_origin == CoordOrigin.TOPLEFT
assert (cell.clip_bbox.l, cell.clip_bbox.t, cell.clip_bbox.r, cell.clip_bbox.b) == (
122.0,
792.0 - 698.0,
252.0,
792.0 - 676.0,
)

cell.to_bottom_left_origin(page_height=792.0)
assert cell.clip_bbox == clip

def test_origin_conversion_without_a_clip(self):
cell = _pdf_text_cell()
cell.to_top_left_origin(page_height=792.0)
assert cell.clip_bbox is None

@pytest.mark.parametrize("query_origin", [CoordOrigin.TOPLEFT, CoordOrigin.BOTTOMLEFT])
def test_get_cells_in_bbox_moves_the_clip_with_the_rect(self, query_origin: CoordOrigin):
"""A cell returned in the query's origin carries its clip in that origin too."""
page_height = 792.0
clip = BoundingBox(l=122.0, b=676.0, r=252.0, t=698.0, coord_origin=CoordOrigin.BOTTOMLEFT)
cell = _pdf_text_cell(clip_bbox=clip)
if query_origin == CoordOrigin.BOTTOMLEFT:
# Store the page's cells in the other origin, so the query flips them back.
cell.to_top_left_origin(page_height=page_height)
page = _segmented_pdf_page(page_height=page_height, word_cells=[cell])

# A query box, in the requested origin, that covers the whole cell.
query = BoundingBox(l=100.0, b=670.0, r=320.0, t=700.0, coord_origin=CoordOrigin.BOTTOMLEFT)
if query_origin == CoordOrigin.TOPLEFT:
query = query.to_top_left_origin(page_height=page_height)

(found,) = page.get_cells_in_bbox(TextCellUnit.WORD, query)
assert isinstance(found, PdfTextCell)
assert found.rect.coord_origin == query_origin
assert found.clip_bbox is not None
assert found.clip_bbox.coord_origin == query_origin
expected = clip if query_origin == CoordOrigin.BOTTOMLEFT else clip.to_top_left_origin(page_height=page_height)
assert found.clip_bbox == expected
# The rect and the clip still agree on where the visible text is.
assert found.clip_bbox.intersection_over_self(found.to_bounding_box()) > 0.0
# The page's own cell is untouched.
assert page.word_cells[0] == cell
Loading