COnsolidation of bbox pipelin and add tests
This commit is contained in:
parent
6cdd00ed1f
commit
b45bb87339
7 changed files with 401 additions and 28 deletions
Binary file not shown.
|
|
@ -9,18 +9,42 @@ bboxes are normalized to TOPLEFT [left, top, right, bottom] before
|
||||||
being sent to the frontend.
|
being sent to the frontend.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from docling_core.types.doc.base import BoundingBox
|
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]:
|
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.
|
"""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:
|
Args:
|
||||||
bbox: Docling BoundingBox (any origin).
|
bbox: Docling BoundingBox (any origin).
|
||||||
page_height: Height of the page (needed for BOTTOMLEFT conversion).
|
page_height: Height of the page (needed for BOTTOMLEFT conversion).
|
||||||
|
|
||||||
Returns:
|
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)
|
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]
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,13 @@ def _process_content_item(
|
||||||
try:
|
try:
|
||||||
page_no = prov.page_no
|
page_no = prov.page_no
|
||||||
if page_no not in pages:
|
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)
|
pages[page_no] = PageDetail(page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT)
|
||||||
|
|
||||||
page_height = pages[page_no].height
|
page_height = pages[page_no].height
|
||||||
|
|
|
||||||
|
|
@ -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
|
import pytest
|
||||||
from docling_core.types.doc.base import BoundingBox, CoordOrigin
|
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):
|
def test_topleft_origin_unchanged(self):
|
||||||
"""TOPLEFT bbox should pass through unchanged."""
|
"""TOPLEFT bbox should pass through unchanged."""
|
||||||
|
|
@ -17,8 +26,6 @@ class TestToTopleftList:
|
||||||
|
|
||||||
def test_bottomleft_origin_converted(self):
|
def test_bottomleft_origin_converted(self):
|
||||||
"""BOTTOMLEFT bbox should have y-coordinates flipped."""
|
"""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)
|
bbox = BoundingBox(l=50, t=700, r=200, b=600, coord_origin=CoordOrigin.BOTTOMLEFT)
|
||||||
result = to_topleft_list(bbox, page_height=792.0)
|
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)
|
bbox = BoundingBox(l=0, t=0, r=612, b=792, coord_origin=CoordOrigin.TOPLEFT)
|
||||||
result = to_topleft_list(bbox, page_height=792.0)
|
result = to_topleft_list(bbox, page_height=792.0)
|
||||||
assert result == [0, 0, 612, 792]
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { computeScale, bboxToRect, pointInRect } from './bboxScaling'
|
import { computeScale, bboxToRect, pointInRect, EMPTY_RECT } from './bboxScaling'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// computeScale
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('computeScale', () => {
|
describe('computeScale', () => {
|
||||||
it('returns 1:1 when display matches page', () => {
|
it('returns 1:1 when display matches page', () => {
|
||||||
|
|
@ -15,15 +19,54 @@ describe('computeScale', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sx equals sy when aspect ratio is preserved', () => {
|
it('sx equals sy when aspect ratio is preserved', () => {
|
||||||
// Image at 150 DPI displayed at 700px wide
|
|
||||||
const pageW = 612, pageH = 792
|
const pageW = 612, pageH = 792
|
||||||
const displayW = 700
|
const displayW = 700
|
||||||
const displayH = displayW * pageH / pageW // preserves ratio
|
const displayH = displayW * pageH / pageW
|
||||||
const s = computeScale(displayW, displayH, pageW, pageH)
|
const s = computeScale(displayW, displayH, pageW, pageH)
|
||||||
expect(s.sx).toBeCloseTo(s.sy, 5)
|
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', () => {
|
describe('bboxToRect', () => {
|
||||||
it('maps page coordinates to pixel rect at scale 1', () => {
|
it('maps page coordinates to pixel rect at scale 1', () => {
|
||||||
const scale = { sx: 1, sy: 1 }
|
const scale = { sx: 1, sy: 1 }
|
||||||
|
|
@ -54,8 +97,66 @@ describe('bboxToRect', () => {
|
||||||
expect(rect.w).toBeCloseTo(700)
|
expect(rect.w).toBeCloseTo(700)
|
||||||
expect(rect.h).toBeCloseTo(907.84, 0)
|
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', () => {
|
describe('pointInRect', () => {
|
||||||
const rect = { x: 10, y: 20, w: 100, h: 60 }
|
const rect = { x: 10, y: 20, w: 100, h: 60 }
|
||||||
|
|
||||||
|
|
@ -64,13 +165,80 @@ describe('pointInRect', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns true for point on edge', () => {
|
it('returns true for point on edge', () => {
|
||||||
expect(pointInRect(10, 20, rect)).toBe(true)
|
expect(pointInRect(10, 20, rect)).toBe(true) // top-left
|
||||||
expect(pointInRect(110, 80, rect)).toBe(true)
|
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', () => {
|
it('returns false for point outside', () => {
|
||||||
expect(pointInRect(5, 50, rect)).toBe(false)
|
expect(pointInRect(5, 50, rect)).toBe(false) // left
|
||||||
expect(pointInRect(50, 15, rect)).toBe(false)
|
expect(pointInRect(50, 15, rect)).toBe(false) // above
|
||||||
expect(pointInRect(115, 50, rect)).toBe(false)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,54 @@
|
||||||
import type { Scale, Rect } from '../../shared/types'
|
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(
|
export function computeScale(
|
||||||
displayWidth: number,
|
displayWidth: number,
|
||||||
displayHeight: number,
|
displayHeight: number,
|
||||||
pageWidth: number,
|
pageWidth: number,
|
||||||
pageHeight: number,
|
pageHeight: number,
|
||||||
): Scale {
|
): Scale {
|
||||||
|
// Guard: avoid division by zero when page dimensions are missing.
|
||||||
|
if (pageWidth <= 0 || pageHeight <= 0) {
|
||||||
|
return { sx: 1, sy: 1 }
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
sx: displayWidth / pageWidth,
|
sx: displayWidth / pageWidth,
|
||||||
sy: displayHeight / pageHeight,
|
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 {
|
export function bboxToRect(bbox: [number, number, number, number], scale: Scale): Rect {
|
||||||
const [l, t, r, b] = bbox
|
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 {
|
return {
|
||||||
x: l * scale.sx,
|
x: l * scale.sx,
|
||||||
y: t * scale.sy,
|
y: t * scale.sy,
|
||||||
w: (r - l) * scale.sx,
|
w,
|
||||||
h: (b - t) * scale.sy,
|
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 {
|
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
|
return px >= rect.x && px <= rect.x + rect.w && py >= rect.y && py <= rect.y + rect.h
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,13 @@ const visibleElements = computed(() => {
|
||||||
return props.pageData.elements.filter((e: PageElement) => !hiddenTypes.has(e.type))
|
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 {
|
function toggleType(type: string): void {
|
||||||
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
|
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
|
||||||
else hiddenTypes.add(type)
|
else hiddenTypes.add(type)
|
||||||
|
|
@ -90,25 +97,28 @@ function draw(): void {
|
||||||
|
|
||||||
if (!img.clientWidth || !img.clientHeight) return
|
if (!img.clientWidth || !img.clientHeight) return
|
||||||
|
|
||||||
canvas.width = img.clientWidth
|
// Retina support: scale the canvas backing store by devicePixelRatio
|
||||||
canvas.height = img.clientHeight
|
// 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')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) return
|
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
|
if (!props.pageData) return
|
||||||
|
|
||||||
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
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) {
|
for (const el of visibleElements.value) {
|
||||||
const rect = bboxToRect(el.bbox, scale)
|
const rect = bboxToRect(el.bbox, scale)
|
||||||
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
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
|
const isHighlighted = props.highlightedIndex >= 0 && elContentIdx === props.highlightedIndex
|
||||||
|
|
||||||
ctx.strokeStyle = color
|
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 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 found: PageElement | null = null
|
||||||
let foundIdx = -1
|
let foundIdx = -1
|
||||||
for (const el of visibleElements.value) {
|
for (const el of visibleElements.value) {
|
||||||
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
|
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
|
||||||
found = el
|
found = el
|
||||||
foundIdx = contentElements.indexOf(el)
|
foundIdx = contentElements.value.indexOf(el)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue