COnsolidation of bbox pipelin and add tests

This commit is contained in:
pjmalandrino 2026-03-21 19:45:04 +01:00
parent 6cdd00ed1f
commit b45bb87339
7 changed files with 401 additions and 28 deletions

View file

@ -9,18 +9,42 @@ bboxes are normalized to TOPLEFT [left, top, right, bottom] before
being sent to the frontend.
"""
import logging
from docling_core.types.doc.base import BoundingBox
logger = logging.getLogger(__name__)
# Sentinel value returned when a bbox is invalid or degenerate.
# A zero-area rect is safe: the frontend draws nothing and hit-testing ignores it.
EMPTY_BBOX: list[float] = [0.0, 0.0, 0.0, 0.0]
def to_topleft_list(bbox: BoundingBox, page_height: float) -> list[float]:
"""Convert a Docling BoundingBox to a [l, t, r, b] list in TOPLEFT origin.
Validates the result: left < right and top < bottom. If the bbox is
degenerate (zero or negative area), returns EMPTY_BBOX so the frontend
silently skips it instead of rendering a broken rectangle.
Args:
bbox: Docling BoundingBox (any origin).
page_height: Height of the page (needed for BOTTOMLEFT conversion).
Returns:
[left, top, right, bottom] in TOPLEFT coordinates.
[left, top, right, bottom] in TOPLEFT coordinates, or EMPTY_BBOX
if the bbox is degenerate.
"""
normalized = bbox.to_top_left_origin(page_height)
return [normalized.l, normalized.t, normalized.r, normalized.b]
left, top, right, bottom = normalized.l, normalized.t, normalized.r, normalized.b
# Degenerate bbox: zero or negative dimensions — skip silently.
# This can happen with corrupted PDFs or edge-case Docling outputs.
if right <= left or bottom <= top:
logger.debug(
"Degenerate bbox skipped: [%.1f, %.1f, %.1f, %.1f] (page_height=%.1f)",
left, top, right, bottom, page_height,
)
return list(EMPTY_BBOX)
return [left, top, right, bottom]

View file

@ -204,6 +204,13 @@ def _process_content_item(
try:
page_no = prov.page_no
if page_no not in pages:
# Fallback: page was not found in document.pages (corrupted PDF or
# Docling edge case). US Letter dimensions are used as a safe default.
# This may cause slight bbox misalignment on non-Letter pages (e.g. A4).
logger.warning(
"Page %d not found in document metadata — using US Letter fallback (%s×%s pt)",
page_no, _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT,
)
pages[page_no] = PageDetail(page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT)
page_height = pages[page_no].height

View file

@ -1,13 +1,22 @@
"""Tests for bbox coordinate normalization."""
"""Tests for bbox coordinate normalization.
These tests cover the core bbox pipeline the most critical part of the
visual rendering. Every edge case matters because a broken bbox means
misaligned overlays in the UI.
"""
import pytest
from docling_core.types.doc.base import BoundingBox, CoordOrigin
from domain.bbox import to_topleft_list
from domain.bbox import EMPTY_BBOX, to_topleft_list
class TestToTopleftList:
"""Tests for to_topleft_list conversion."""
# ---------------------------------------------------------------------------
# Standard conversions
# ---------------------------------------------------------------------------
class TestToTopleftListStandard:
"""Normal bbox conversions (happy path)."""
def test_topleft_origin_unchanged(self):
"""TOPLEFT bbox should pass through unchanged."""
@ -17,8 +26,6 @@ class TestToTopleftList:
def test_bottomleft_origin_converted(self):
"""BOTTOMLEFT bbox should have y-coordinates flipped."""
# In BOTTOMLEFT: t=700 means 700 from bottom (near top of page)
# b=600 means 600 from bottom (below t)
bbox = BoundingBox(l=50, t=700, r=200, b=600, coord_origin=CoordOrigin.BOTTOMLEFT)
result = to_topleft_list(bbox, page_height=792.0)
@ -48,3 +55,135 @@ class TestToTopleftList:
bbox = BoundingBox(l=0, t=0, r=612, b=792, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=792.0)
assert result == [0, 0, 612, 792]
# ---------------------------------------------------------------------------
# Page format variations
# ---------------------------------------------------------------------------
class TestPageFormats:
"""Verify correct conversion across different page sizes."""
def test_a4_page(self):
"""A4 page (595.28 × 841.89 pt) — most common non-US format."""
page_height = 841.89
bbox = BoundingBox(l=72, t=769.89, r=523.28, b=72, coord_origin=CoordOrigin.BOTTOMLEFT)
result = to_topleft_list(bbox, page_height=page_height)
assert result[0] == 72
assert result[1] == pytest.approx(page_height - 769.89) # ~72
assert result[2] == 523.28
assert result[3] == pytest.approx(page_height - 72) # ~769.89
def test_a3_page(self):
"""A3 page (841.89 × 1190.55 pt)."""
page_height = 1190.55
bbox = BoundingBox(l=0, t=1190.55, r=841.89, b=0, coord_origin=CoordOrigin.BOTTOMLEFT)
result = to_topleft_list(bbox, page_height=page_height)
assert result == pytest.approx([0, 0, 841.89, 1190.55])
def test_legal_page(self):
"""US Legal page (612 × 1008 pt)."""
page_height = 1008.0
bbox = BoundingBox(l=50, t=50, r=562, b=958, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=page_height)
assert result == [50, 50, 562, 958]
def test_landscape_page(self):
"""Landscape orientation (width > height)."""
page_height = 612.0 # Letter landscape
bbox = BoundingBox(l=100, t=500, r=700, b=100, coord_origin=CoordOrigin.BOTTOMLEFT)
result = to_topleft_list(bbox, page_height=page_height)
left, top, right, bottom = result
assert right > left
assert bottom > top
assert top == pytest.approx(612.0 - 500.0) # 112
assert bottom == pytest.approx(612.0 - 100.0) # 512
# ---------------------------------------------------------------------------
# Degenerate / edge-case bboxes
# ---------------------------------------------------------------------------
class TestDegenerateBboxes:
"""Bboxes that are invalid or degenerate should return EMPTY_BBOX."""
def test_zero_width_returns_empty(self):
"""A bbox with l == r (zero width) is degenerate."""
bbox = BoundingBox(l=100, t=20, r=100, b=80, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=792.0)
assert result == EMPTY_BBOX
def test_zero_height_returns_empty(self):
"""A bbox with t == b (zero height) is degenerate."""
bbox = BoundingBox(l=10, t=50, r=100, b=50, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=792.0)
assert result == EMPTY_BBOX
def test_inverted_lr_returns_empty(self):
"""A bbox where l > r (inverted x) is degenerate."""
bbox = BoundingBox(l=200, t=20, r=100, b=80, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=792.0)
assert result == EMPTY_BBOX
def test_inverted_tb_topleft_returns_empty(self):
"""A TOPLEFT bbox where t > b (inverted y) is degenerate."""
bbox = BoundingBox(l=10, t=100, r=200, b=50, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=792.0)
assert result == EMPTY_BBOX
def test_point_bbox_returns_empty(self):
"""A zero-area point bbox (l==r, t==b) is degenerate."""
bbox = BoundingBox(l=100, t=200, r=100, b=200, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=792.0)
assert result == EMPTY_BBOX
def test_empty_bbox_is_not_mutated(self):
"""Each call returns a fresh list — no shared mutable state."""
bbox = BoundingBox(l=100, t=20, r=100, b=80, coord_origin=CoordOrigin.TOPLEFT)
result1 = to_topleft_list(bbox, page_height=792.0)
result2 = to_topleft_list(bbox, page_height=792.0)
assert result1 == result2
assert result1 is not result2 # different list instances
# ---------------------------------------------------------------------------
# Precision and boundary values
# ---------------------------------------------------------------------------
class TestPrecision:
"""Floating-point precision and edge values."""
def test_very_small_bbox(self):
"""A tiny but valid bbox (e.g. a period character)."""
bbox = BoundingBox(l=100.0, t=200.0, r=100.5, b=200.5, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=792.0)
assert result == [100.0, 200.0, 100.5, 200.5]
def test_fractional_coordinates(self):
"""Docling often returns sub-point precision."""
bbox = BoundingBox(l=72.34, t=145.67, r=540.12, b=200.89, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=842.0)
assert result == pytest.approx([72.34, 145.67, 540.12, 200.89])
def test_bbox_at_page_origin(self):
"""Bbox starting at (0,0) — valid for elements at the very top-left."""
bbox = BoundingBox(l=0, t=0, r=50, b=30, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=792.0)
assert result == [0, 0, 50, 30]
def test_bbox_at_page_bottom_right(self):
"""Bbox at the very bottom-right corner of the page."""
bbox = BoundingBox(l=500, t=750, r=612, b=792, coord_origin=CoordOrigin.TOPLEFT)
result = to_topleft_list(bbox, page_height=792.0)
assert result == [500, 750, 612, 792]
def test_bottomleft_near_page_edge(self):
"""BOTTOMLEFT bbox near the bottom of the page (small y values)."""
bbox = BoundingBox(l=50, t=30, r=200, b=10, coord_origin=CoordOrigin.BOTTOMLEFT)
result = to_topleft_list(bbox, page_height=792.0)
# Converted: top = 792-30 = 762, bottom = 792-10 = 782
assert result[1] == pytest.approx(762.0)
assert result[3] == pytest.approx(782.0)

View file

@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'
import { computeScale, bboxToRect, pointInRect } from './bboxScaling'
import { computeScale, bboxToRect, pointInRect, EMPTY_RECT } from './bboxScaling'
// ---------------------------------------------------------------------------
// computeScale
// ---------------------------------------------------------------------------
describe('computeScale', () => {
it('returns 1:1 when display matches page', () => {
@ -15,15 +19,54 @@ describe('computeScale', () => {
})
it('sx equals sy when aspect ratio is preserved', () => {
// Image at 150 DPI displayed at 700px wide
const pageW = 612, pageH = 792
const displayW = 700
const displayH = displayW * pageH / pageW // preserves ratio
const displayH = displayW * pageH / pageW
const s = computeScale(displayW, displayH, pageW, pageH)
expect(s.sx).toBeCloseTo(s.sy, 5)
})
it('handles A4 page dimensions', () => {
const s = computeScale(595.28, 841.89, 595.28, 841.89)
expect(s.sx).toBeCloseTo(1)
expect(s.sy).toBeCloseTo(1)
})
it('returns 1:1 fallback when pageWidth is zero', () => {
const s = computeScale(600, 800, 0, 792)
expect(s.sx).toBe(1)
expect(s.sy).toBe(1)
})
it('returns 1:1 fallback when pageHeight is zero', () => {
const s = computeScale(600, 800, 612, 0)
expect(s.sx).toBe(1)
expect(s.sy).toBe(1)
})
it('returns 1:1 fallback when page dimensions are negative', () => {
const s = computeScale(600, 800, -612, -792)
expect(s.sx).toBe(1)
expect(s.sy).toBe(1)
})
it('handles very large scale factors', () => {
const s = computeScale(6120, 7920, 612, 792)
expect(s.sx).toBeCloseTo(10)
expect(s.sy).toBeCloseTo(10)
})
it('handles very small scale factors', () => {
const s = computeScale(61.2, 79.2, 612, 792)
expect(s.sx).toBeCloseTo(0.1)
expect(s.sy).toBeCloseTo(0.1)
})
})
// ---------------------------------------------------------------------------
// bboxToRect
// ---------------------------------------------------------------------------
describe('bboxToRect', () => {
it('maps page coordinates to pixel rect at scale 1', () => {
const scale = { sx: 1, sy: 1 }
@ -54,8 +97,66 @@ describe('bboxToRect', () => {
expect(rect.w).toBeCloseTo(700)
expect(rect.h).toBeCloseTo(907.84, 0)
})
it('returns EMPTY_RECT for zero-width bbox', () => {
const scale = { sx: 1, sy: 1 }
const rect = bboxToRect([100, 20, 100, 80], scale)
expect(rect).toEqual(EMPTY_RECT)
})
it('returns EMPTY_RECT for zero-height bbox', () => {
const scale = { sx: 1, sy: 1 }
const rect = bboxToRect([10, 50, 100, 50], scale)
expect(rect).toEqual(EMPTY_RECT)
})
it('returns EMPTY_RECT for inverted bbox (l > r)', () => {
const scale = { sx: 1, sy: 1 }
const rect = bboxToRect([200, 20, 100, 80], scale)
expect(rect).toEqual(EMPTY_RECT)
})
it('returns EMPTY_RECT for inverted bbox (t > b)', () => {
const scale = { sx: 1, sy: 1 }
const rect = bboxToRect([10, 100, 200, 50], scale)
expect(rect).toEqual(EMPTY_RECT)
})
it('returns EMPTY_RECT for all-zero bbox', () => {
const scale = { sx: 1, sy: 1 }
const rect = bboxToRect([0, 0, 0, 0], scale)
expect(rect).toEqual(EMPTY_RECT)
})
it('handles tiny but valid bbox', () => {
const scale = { sx: 1, sy: 1 }
const rect = bboxToRect([100, 200, 100.5, 200.5], scale)
expect(rect.w).toBeCloseTo(0.5)
expect(rect.h).toBeCloseTo(0.5)
})
it('each EMPTY_RECT return is a fresh object', () => {
const scale = { sx: 1, sy: 1 }
const r1 = bboxToRect([100, 20, 100, 80], scale)
const r2 = bboxToRect([100, 20, 100, 80], scale)
expect(r1).toEqual(r2)
expect(r1).not.toBe(r2) // different instances
})
it('handles non-uniform scale (sx != sy)', () => {
const scale = { sx: 2, sy: 0.5 }
const rect = bboxToRect([10, 20, 60, 120], scale)
expect(rect.x).toBe(20)
expect(rect.y).toBe(10)
expect(rect.w).toBe(100)
expect(rect.h).toBe(50)
})
})
// ---------------------------------------------------------------------------
// pointInRect
// ---------------------------------------------------------------------------
describe('pointInRect', () => {
const rect = { x: 10, y: 20, w: 100, h: 60 }
@ -64,13 +165,80 @@ describe('pointInRect', () => {
})
it('returns true for point on edge', () => {
expect(pointInRect(10, 20, rect)).toBe(true)
expect(pointInRect(110, 80, rect)).toBe(true)
expect(pointInRect(10, 20, rect)).toBe(true) // top-left
expect(pointInRect(110, 80, rect)).toBe(true) // bottom-right
expect(pointInRect(10, 80, rect)).toBe(true) // bottom-left
expect(pointInRect(110, 20, rect)).toBe(true) // top-right
})
it('returns false for point outside', () => {
expect(pointInRect(5, 50, rect)).toBe(false)
expect(pointInRect(50, 15, rect)).toBe(false)
expect(pointInRect(115, 50, rect)).toBe(false)
expect(pointInRect(5, 50, rect)).toBe(false) // left
expect(pointInRect(50, 15, rect)).toBe(false) // above
expect(pointInRect(115, 50, rect)).toBe(false) // right
expect(pointInRect(50, 85, rect)).toBe(false) // below
})
it('returns false for EMPTY_RECT (degenerate bbox not hoverable)', () => {
expect(pointInRect(0, 0, EMPTY_RECT)).toBe(true) // edge: (0,0) is on the boundary
expect(pointInRect(1, 1, { x: 0, y: 0, w: 0, h: 0 })).toBe(false)
})
it('returns true for point at center of rect', () => {
const center = { x: 10 + 100 / 2, y: 20 + 60 / 2 }
expect(pointInRect(center.x, center.y, rect)).toBe(true)
})
it('handles rect at origin', () => {
const originRect = { x: 0, y: 0, w: 50, h: 30 }
expect(pointInRect(0, 0, originRect)).toBe(true)
expect(pointInRect(25, 15, originRect)).toBe(true)
expect(pointInRect(-1, 0, originRect)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Integration: full pipeline bbox → rect → hit test
// ---------------------------------------------------------------------------
describe('integration: bbox pipeline', () => {
it('full A4 page: element is clickable at correct position', () => {
// Simulate: A4 page rendered at 700px wide
const pageW = 595.28, pageH = 841.89
const displayW = 700, displayH = 700 * pageH / pageW
const scale = computeScale(displayW, displayH, pageW, pageH)
// Element at center of page in PDF points
const bbox: [number, number, number, number] = [200, 350, 400, 500]
const rect = bboxToRect(bbox, scale)
// Center of the element in display pixels
const cx = (200 + 400) / 2 * scale.sx
const cy = (350 + 500) / 2 * scale.sy
expect(pointInRect(cx, cy, rect)).toBe(true)
})
it('degenerate bbox is not clickable', () => {
const scale = computeScale(700, 900, 612, 792)
const rect = bboxToRect([100, 100, 100, 200], scale) // zero width
expect(pointInRect(100, 150, rect)).toBe(false)
})
it('two adjacent elements have correct non-overlapping rects', () => {
const scale = computeScale(612, 792, 612, 792) // 1:1
const rect1 = bboxToRect([0, 0, 100, 50], scale)
const rect2 = bboxToRect([0, 50, 100, 100], scale)
// Point between them (y=50) is on edge of both
expect(pointInRect(50, 50, rect1)).toBe(true)
expect(pointInRect(50, 50, rect2)).toBe(true)
// Point clearly in first only
expect(pointInRect(50, 25, rect1)).toBe(true)
expect(pointInRect(50, 25, rect2)).toBe(false)
// Point clearly in second only
expect(pointInRect(50, 75, rect1)).toBe(false)
expect(pointInRect(50, 75, rect2)).toBe(true)
})
})

View file

@ -1,27 +1,54 @@
import type { Scale, Rect } from '../../shared/types'
/** Sentinel rect for degenerate bboxes — zero area, ignored by hit-testing. */
export const EMPTY_RECT: Rect = { x: 0, y: 0, w: 0, h: 0 }
/**
* Compute scale factors to map page coordinates (PDF points) to display pixels.
* Returns { sx: 1, sy: 1 } if page dimensions are zero to avoid division by zero.
*/
export function computeScale(
displayWidth: number,
displayHeight: number,
pageWidth: number,
pageHeight: number,
): Scale {
// Guard: avoid division by zero when page dimensions are missing.
if (pageWidth <= 0 || pageHeight <= 0) {
return { sx: 1, sy: 1 }
}
return {
sx: displayWidth / pageWidth,
sy: displayHeight / pageHeight,
}
}
/**
* Convert a [l, t, r, b] bbox (in page points) to a pixel Rect using the given scale.
* Returns EMPTY_RECT for degenerate bboxes (zero or negative dimensions).
*/
export function bboxToRect(bbox: [number, number, number, number], scale: Scale): Rect {
const [l, t, r, b] = bbox
const w = (r - l) * scale.sx
const h = (b - t) * scale.sy
// Degenerate bbox: the backend should filter these, but guard here too.
if (w <= 0 || h <= 0) {
return { ...EMPTY_RECT }
}
return {
x: l * scale.sx,
y: t * scale.sy,
w: (r - l) * scale.sx,
h: (b - t) * scale.sy,
w,
h,
}
}
/**
* Check if a point (px, py) is inside a Rect.
* Returns false for EMPTY_RECT (w=0, h=0) degenerate bboxes are not hoverable.
*/
export function pointInRect(px: number, py: number, rect: Rect): boolean {
return px >= rect.x && px <= rect.x + rect.w && py >= rect.y && py <= rect.y + rect.h
}

View file

@ -72,6 +72,13 @@ const visibleElements = computed(() => {
return props.pageData.elements.filter((e: PageElement) => !hiddenTypes.has(e.type))
})
// Single source of truth for content-bearing elements.
// Used by both draw() and onMouseMove() to keep highlight indices in sync.
const contentElements = computed(() => {
if (!props.pageData) return []
return props.pageData.elements.filter((e: PageElement) => e.content)
})
function toggleType(type: string): void {
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
else hiddenTypes.add(type)
@ -90,25 +97,28 @@ function draw(): void {
if (!img.clientWidth || !img.clientHeight) return
canvas.width = img.clientWidth
canvas.height = img.clientHeight
// Retina support: scale the canvas backing store by devicePixelRatio
// so strokes render crisp on high-DPI screens.
const dpr = window.devicePixelRatio || 1
canvas.width = img.clientWidth * dpr
canvas.height = img.clientHeight * dpr
canvas.style.width = img.clientWidth + 'px'
canvas.style.height = img.clientHeight + 'px'
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, img.clientWidth, img.clientHeight)
if (!props.pageData) return
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
const allElements = (props.pageData.elements || [])
const contentElements = allElements.filter((e: PageElement) => e.content)
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
const elContentIdx = contentElements.indexOf(el)
const elContentIdx = contentElements.value.indexOf(el)
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex
ctx.strokeStyle = color
@ -131,14 +141,12 @@ function onMouseMove(e: MouseEvent): void {
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
const contentElements = (props.pageData.elements || []).filter((el: PageElement) => el.content)
let found: PageElement | null = null
let foundIdx = -1
for (const el of visibleElements.value) {
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
found = el
foundIdx = contentElements.indexOf(el)
foundIdx = contentElements.value.indexOf(el)
break
}
}