diff --git a/backend/src/main/java/com/docling/studio/analysis/AnalysisService.java b/backend/src/main/java/com/docling/studio/analysis/AnalysisService.java index ffb08cd..133b3ba 100644 --- a/backend/src/main/java/com/docling/studio/analysis/AnalysisService.java +++ b/backend/src/main/java/com/docling/studio/analysis/AnalysisService.java @@ -61,6 +61,7 @@ public class AnalysisService { if (pageCount instanceof Number n && n.intValue() > 0) { Document doc = job.getDocument(); doc.setPageCount(n.intValue()); + documentService.save(doc); } job.markCompleted(markdown, html, pagesJson); diff --git a/backend/src/main/java/com/docling/studio/document/DocumentService.java b/backend/src/main/java/com/docling/studio/document/DocumentService.java index 99639eb..ef6073d 100644 --- a/backend/src/main/java/com/docling/studio/document/DocumentService.java +++ b/backend/src/main/java/com/docling/studio/document/DocumentService.java @@ -53,6 +53,10 @@ public class DocumentService { } } + public Document save(Document document) { + return repository.save(document); + } + public List findAll() { return repository.findAll(); } diff --git a/document-parser/bbox.py b/document-parser/bbox.py new file mode 100644 index 0000000..b1498f2 --- /dev/null +++ b/document-parser/bbox.py @@ -0,0 +1,26 @@ +"""Bounding box coordinate normalization for Docling output. + +Docling's BoundingBox uses two possible coordinate origins: +- TOPLEFT: y=0 at top, t < b (t is smaller, closer to origin) +- BOTTOMLEFT: y=0 at bottom, t > b (t is larger, further from origin) + +The frontend canvas uses TOPLEFT coordinates. This module ensures all +bboxes are normalized to TOPLEFT [left, top, right, bottom] before +being sent to the frontend. +""" + +from docling_core.types.doc.base import BoundingBox, CoordOrigin + + +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. + + Args: + bbox: Docling BoundingBox (any origin). + page_height: Height of the page (needed for BOTTOMLEFT conversion). + + Returns: + [left, top, right, bottom] in TOPLEFT coordinates. + """ + normalized = bbox.to_top_left_origin(page_height) + return [normalized.l, normalized.t, normalized.r, normalized.b] diff --git a/document-parser/main.py b/document-parser/main.py index bf6878c..efa66bf 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -1,4 +1,5 @@ import io +import logging import os import tempfile from pathlib import Path @@ -10,6 +11,10 @@ from docling.document_converter import DocumentConverter from pdf2image import convert_from_bytes from PIL import Image +from bbox import to_topleft_list + +logger = logging.getLogger(__name__) + app = FastAPI(title="Docling Studio - Document Parser") converter = DocumentConverter() @@ -43,7 +48,11 @@ class ParseResponse(BaseModel): # --- Helpers --- def extract_pages_detail(doc_result) -> list[PageDetail]: - """Extract per-page element details with bounding boxes from Docling result.""" + """Extract per-page element details with bounding boxes from Docling result. + + Uses Docling's iterate_items() API (preferred) or falls back to document.texts. + Both provide a flat iteration over all content items, avoiding duplicates. + """ pages: dict[int, PageDetail] = {} document = doc_result.document @@ -60,12 +69,11 @@ def extract_pages_detail(doc_result) -> list[PageDetail]: elements=[] ) - # Iterate document items to extract elements with bounding boxes - if hasattr(document, 'body') and hasattr(document.body, 'children'): - _extract_elements_recursive(document, document.body.children, pages) - - # Fallback: if we have content items - if hasattr(document, 'texts'): + # Use iterate_items() (Docling v2 API) — avoids duplicates + if hasattr(document, 'iterate_items'): + for item, _level in document.iterate_items(): + _process_content_item(item, pages) + elif hasattr(document, 'texts'): for text_item in document.texts: _process_content_item(text_item, pages) @@ -74,53 +82,46 @@ def extract_pages_detail(doc_result) -> list[PageDetail]: def _process_content_item(item, pages: dict[int, PageDetail]): - """Process a single content item and add it to the appropriate page.""" + """Process a single content item and add it to the appropriate page. + + Silently skips items that lack provenance or fail to process, + so one bad item doesn't break the whole extraction. + """ if not hasattr(item, 'prov') or not item.prov: return for prov in item.prov: - page_no = prov.page_no if hasattr(prov, 'page_no') else 1 + try: + page_no = prov.page_no if hasattr(prov, 'page_no') else 1 - if page_no not in pages: - pages[page_no] = PageDetail( - page_number=page_no, width=612.0, height=792.0, elements=[] - ) + if page_no not in pages: + pages[page_no] = PageDetail( + page_number=page_no, width=612.0, height=792.0, elements=[] + ) - bbox = [0, 0, 0, 0] - if hasattr(prov, 'bbox') and prov.bbox: - b = prov.bbox - if hasattr(b, 'l'): - bbox = [b.l, b.t, b.r, b.b] - elif isinstance(b, (list, tuple)) and len(b) >= 4: - bbox = list(b[:4]) + page_height = pages[page_no].height - element_type = _get_element_type(item) - content = "" - if hasattr(item, 'text'): - content = item.text or "" - elif hasattr(item, 'export_to_markdown'): - content = item.export_to_markdown() + bbox = [0, 0, 0, 0] + if hasattr(prov, 'bbox') and prov.bbox: + b = prov.bbox + if hasattr(b, 'l'): + bbox = to_topleft_list(b, page_height) + elif isinstance(b, (list, tuple)) and len(b) >= 4: + bbox = list(b[:4]) - pages[page_no].elements.append(PageElement( - type=element_type, - bbox=bbox, - content=content[:500] # Truncate long content - )) + element_type = _get_element_type(item) + content = "" + if hasattr(item, 'text'): + content = item.text or "" + pages[page_no].elements.append(PageElement( + type=element_type, + bbox=bbox, + content=content[:500] + )) + except Exception: + logger.warning("Skipping item %s: failed to process", type(item).__name__, exc_info=True) -def _extract_elements_recursive(document, children, pages: dict[int, PageDetail]): - """Recursively extract elements from document tree.""" - if not children: - return - for child_ref in children: - # Resolve reference if needed - item = child_ref - if hasattr(child_ref, '__ref__'): - item = document.resolve_ref(child_ref) - if item: - _process_content_item(item, pages) - if hasattr(item, 'children') and item.children: - _extract_elements_recursive(document, item.children, pages) def _get_element_type(item) -> str: @@ -197,6 +198,7 @@ async def parse(file: UploadFile): ) except Exception as e: + logger.exception("Failed to parse document: %s", file.filename) raise HTTPException(status_code=422, detail=f"Failed to parse document: {str(e)}") finally: if tmp_path and os.path.exists(tmp_path): diff --git a/document-parser/test_bbox.py b/document-parser/test_bbox.py new file mode 100644 index 0000000..2bfd5a7 --- /dev/null +++ b/document-parser/test_bbox.py @@ -0,0 +1,50 @@ +"""Tests for bbox coordinate normalization.""" + +import pytest +from docling_core.types.doc.base import BoundingBox, CoordOrigin + +from bbox import to_topleft_list + + +class TestToTopleftList: + """Tests for to_topleft_list conversion.""" + + def test_topleft_origin_unchanged(self): + """TOPLEFT bbox should pass through unchanged.""" + bbox = BoundingBox(l=10, t=20, r=100, b=80, coord_origin=CoordOrigin.TOPLEFT) + result = to_topleft_list(bbox, page_height=792.0) + assert result == [10, 20, 100, 80] + + 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) + + # After conversion: new_t = 792 - 700 = 92, new_b = 792 - 600 = 192 + assert result[0] == 50 # l unchanged + assert result[1] == pytest.approx(92.0) # t = page_height - old_t + assert result[2] == 200 # r unchanged + assert result[3] == pytest.approx(192.0) # b = page_height - old_b + + def test_result_has_positive_dimensions(self): + """Converted bbox should always have b > t (positive height).""" + bbox = BoundingBox(l=10, t=500, r=300, b=100, coord_origin=CoordOrigin.BOTTOMLEFT) + result = to_topleft_list(bbox, page_height=800.0) + + l, t, r, b = result + assert r > l, "width should be positive" + assert b > t, "height should be positive" + + def test_full_page_bbox_bottomleft(self): + """A bbox covering the full page in BOTTOMLEFT origin.""" + bbox = BoundingBox(l=0, t=792, r=612, b=0, coord_origin=CoordOrigin.BOTTOMLEFT) + result = to_topleft_list(bbox, page_height=792.0) + assert result == [0, 0, 612, 792] + + def test_full_page_bbox_topleft(self): + """A bbox covering the full page in TOPLEFT origin.""" + 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] diff --git a/frontend/src/features/analysis/bboxScaling.js b/frontend/src/features/analysis/bboxScaling.js new file mode 100644 index 0000000..e96852d --- /dev/null +++ b/frontend/src/features/analysis/bboxScaling.js @@ -0,0 +1,58 @@ +/** + * Bbox scaling utilities for mapping Docling TOPLEFT coordinates + * to canvas pixel coordinates. + * + * Docling bbox format (after backend normalization): [l, t, r, b] + * All values are in page coordinate space (points, 1pt = 1/72 inch). + * Origin: top-left (y=0 at top of page). + * + * The preview image is a faithful rasterization of the PDF page. + * CSS `max-width: 100%; height: auto` preserves the aspect ratio, + * so sx and sy are always equal. We compute both for completeness. + */ + +/** + * Compute scale factors from page coordinates to displayed pixels. + * + * @param {number} displayWidth - img.clientWidth (CSS pixels) + * @param {number} displayHeight - img.clientHeight (CSS pixels) + * @param {number} pageWidth - Page width in Docling points + * @param {number} pageHeight - Page height in Docling points + * @returns {{ sx: number, sy: number }} + */ +export function computeScale(displayWidth, displayHeight, pageWidth, pageHeight) { + return { + sx: displayWidth / pageWidth, + sy: displayHeight / pageHeight, + } +} + +/** + * Convert a Docling bbox [l, t, r, b] to a canvas rect { x, y, w, h }. + * + * @param {number[]} bbox - [left, top, right, bottom] in page points + * @param {{ sx: number, sy: number }} scale + * @returns {{ x: number, y: number, w: number, h: number }} + */ +export function bboxToRect(bbox, scale) { + const [l, t, r, b] = bbox + return { + x: l * scale.sx, + y: t * scale.sy, + w: (r - l) * scale.sx, + h: (b - t) * scale.sy, + } +} + +/** + * Test if a point (px, py) falls inside a rect. + * + * @param {number} px + * @param {number} py + * @param {{ x: number, y: number, w: number, h: number }} rect + * @returns {boolean} + */ +export function pointInRect(px, py, rect) { + return px >= rect.x && px <= rect.x + rect.w && + py >= rect.y && py <= rect.y + rect.h +} diff --git a/frontend/src/features/analysis/bboxScaling.test.js b/frontend/src/features/analysis/bboxScaling.test.js new file mode 100644 index 0000000..24987be --- /dev/null +++ b/frontend/src/features/analysis/bboxScaling.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import { computeScale, bboxToRect, pointInRect } from './bboxScaling.js' + +describe('computeScale', () => { + it('returns 1:1 when display matches page', () => { + const s = computeScale(612, 792, 612, 792) + expect(s.sx).toBe(1) + expect(s.sy).toBe(1) + }) + + it('scales proportionally', () => { + const s = computeScale(306, 396, 612, 792) + expect(s.sx).toBeCloseTo(0.5) + expect(s.sy).toBeCloseTo(0.5) + }) + + 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 s = computeScale(displayW, displayH, pageW, pageH) + expect(s.sx).toBeCloseTo(s.sy, 5) + }) +}) + +describe('bboxToRect', () => { + it('maps page coordinates to pixel rect at scale 1', () => { + const scale = { sx: 1, sy: 1 } + const rect = bboxToRect([10, 20, 110, 80], scale) + expect(rect).toEqual({ x: 10, y: 20, w: 100, h: 60 }) + }) + + it('scales correctly at 2x', () => { + const scale = { sx: 2, sy: 2 } + const rect = bboxToRect([10, 20, 60, 70], scale) + expect(rect).toEqual({ x: 20, y: 40, w: 100, h: 100 }) + }) + + it('handles fractional scales', () => { + const scale = { sx: 0.5, sy: 0.5 } + const rect = bboxToRect([100, 200, 300, 400], scale) + expect(rect.x).toBeCloseTo(50) + expect(rect.y).toBeCloseTo(100) + expect(rect.w).toBeCloseTo(100) + expect(rect.h).toBeCloseTo(100) + }) + + it('end-to-end: full page bbox fills display', () => { + const scale = computeScale(700, 907.84, 612, 792) + const rect = bboxToRect([0, 0, 612, 792], scale) + expect(rect.x).toBeCloseTo(0) + expect(rect.y).toBeCloseTo(0) + expect(rect.w).toBeCloseTo(700) + expect(rect.h).toBeCloseTo(907.84, 0) + }) +}) + +describe('pointInRect', () => { + const rect = { x: 10, y: 20, w: 100, h: 60 } + + it('returns true for point inside', () => { + expect(pointInRect(50, 50, rect)).toBe(true) + }) + + it('returns true for point on edge', () => { + expect(pointInRect(10, 20, rect)).toBe(true) + expect(pointInRect(110, 80, rect)).toBe(true) + }) + + 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) + }) +}) diff --git a/frontend/src/features/analysis/index.js b/frontend/src/features/analysis/index.js index 543130e..a6bbd93 100644 --- a/frontend/src/features/analysis/index.js +++ b/frontend/src/features/analysis/index.js @@ -3,4 +3,5 @@ export { default as AnalysisPanel } from './ui/AnalysisPanel.vue' export { default as ResultTabs } from './ui/ResultTabs.vue' export { default as MarkdownViewer } from './ui/MarkdownViewer.vue' export { default as StructureViewer } from './ui/StructureViewer.vue' +export { default as BboxOverlay } from './ui/BboxOverlay.vue' export { default as ImageGallery } from './ui/ImageGallery.vue' diff --git a/frontend/src/features/analysis/ui/BboxOverlay.vue b/frontend/src/features/analysis/ui/BboxOverlay.vue new file mode 100644 index 0000000..131dfca --- /dev/null +++ b/frontend/src/features/analysis/ui/BboxOverlay.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/frontend/src/features/analysis/ui/ResultTabs.vue b/frontend/src/features/analysis/ui/ResultTabs.vue index 69adf3b..98c4c8a 100644 --- a/frontend/src/features/analysis/ui/ResultTabs.vue +++ b/frontend/src/features/analysis/ui/ResultTabs.vue @@ -11,17 +11,18 @@ {{ tab.label }} + + +
+ Page {{ currentPage }} sur {{ totalPages }} +
+
- - +
-
{{ store.currentAnalysis.contentMarkdown }}
+
{{ pageMarkdown }}
- +
@@ -41,21 +42,60 @@