From b45bb873397061538a765befe391c3612d92ae82 Mon Sep 17 00:00:00 2001 From: pjmalandrino Date: Sat, 21 Mar 2026 19:45:04 +0100 Subject: [PATCH] COnsolidation of bbox pipelin and add tests --- document-parser/data/docling_studio.db | Bin 622592 -> 622592 bytes document-parser/domain/bbox.py | 28 ++- document-parser/domain/parsing.py | 7 + document-parser/tests/test_bbox.py | 151 +++++++++++++- .../src/features/analysis/bboxScaling.test.ts | 184 +++++++++++++++++- frontend/src/features/analysis/bboxScaling.ts | 31 ++- .../src/features/analysis/ui/BboxOverlay.vue | 28 ++- 7 files changed, 401 insertions(+), 28 deletions(-) diff --git a/document-parser/data/docling_studio.db b/document-parser/data/docling_studio.db index a4f5f99a7625f274b46cd6a0e6b76138e0830747..17adb2bc07e47926d1dc82533ea7ed98e99d9a98 100644 GIT binary patch delta 99230 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

n}9L@k6&Vu=DLS4Rfc9*2+ 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 CP(tqj delta 377 zcmZwCy-vbV6bJCz+hQ-J^|bgcg7qT=2gFUopuT{Sg~)_wT$(;O7a?bfL$Mf=@mv3jRGGpuv>QL$wwv}?Pen}>`Vn)o0 z&HGeGI_}t#aW=_KP64AQ3VusEm2>igQq|x>FylxFihCztx)eL#y78AhMeYK*!>Zsk zV)Q{Hsd&%iKh`Y&y_Uu{Wb{HIayO){s|QMV_`qF%aJ=!)%^&7mIcOB@J6rvHMKf=& z)h*t>($ol(vftDq6|@E(+5`hEFd+p=C<1{E4y0iUreOwVVGibD0Tv+xOOS;eEQ1Rx cunKE1345>$-SJzZ((FeGm;gQON21vJ0SE_K6#xJL diff --git a/document-parser/domain/bbox.py b/document-parser/domain/bbox.py index c36c590..41530a7 100644 --- a/document-parser/domain/bbox.py +++ b/document-parser/domain/bbox.py @@ -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] diff --git a/document-parser/domain/parsing.py b/document-parser/domain/parsing.py index ab8111b..8cdc943 100644 --- a/document-parser/domain/parsing.py +++ b/document-parser/domain/parsing.py @@ -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 diff --git a/document-parser/tests/test_bbox.py b/document-parser/tests/test_bbox.py index 065c58d..27af6d3 100644 --- a/document-parser/tests/test_bbox.py +++ b/document-parser/tests/test_bbox.py @@ -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) diff --git a/frontend/src/features/analysis/bboxScaling.test.ts b/frontend/src/features/analysis/bboxScaling.test.ts index 325eec7..0114183 100644 --- a/frontend/src/features/analysis/bboxScaling.test.ts +++ b/frontend/src/features/analysis/bboxScaling.test.ts @@ -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) }) }) diff --git a/frontend/src/features/analysis/bboxScaling.ts b/frontend/src/features/analysis/bboxScaling.ts index 3a57e66..3a3b7cd 100644 --- a/frontend/src/features/analysis/bboxScaling.ts +++ b/frontend/src/features/analysis/bboxScaling.ts @@ -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 } diff --git a/frontend/src/features/analysis/ui/BboxOverlay.vue b/frontend/src/features/analysis/ui/BboxOverlay.vue index 7441438..c2c0c17 100644 --- a/frontend/src/features/analysis/ui/BboxOverlay.vue +++ b/frontend/src/features/analysis/ui/BboxOverlay.vue @@ -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 } }