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/main.py b/document-parser/main.py index 6e0a925..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 @@ -12,6 +13,8 @@ from PIL import Image from bbox import to_topleft_list +logger = logging.getLogger(__name__) + app = FastAPI(title="Docling Studio - Document Parser") converter = DocumentConverter() @@ -45,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 @@ -62,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) @@ -76,56 +82,47 @@ 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=[] + ) - page_height = pages[page_no].height + page_height = pages[page_no].height - 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]) + 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]) - 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() + 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] # Truncate long content - )) + 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: """Determine the element type from a Docling document item.""" @@ -201,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/frontend/src/features/analysis/bboxScaling.js b/frontend/src/features/analysis/bboxScaling.js index 8d07edb..e96852d 100644 --- a/frontend/src/features/analysis/bboxScaling.js +++ b/frontend/src/features/analysis/bboxScaling.js @@ -3,15 +3,19 @@ * to canvas pixel coordinates. * * Docling bbox format (after backend normalization): [l, t, r, b] - * All values are in page coordinate space (points). + * 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 image pixels. + * Compute scale factors from page coordinates to displayed pixels. * - * @param {number} displayWidth - Rendered image width in CSS pixels - * @param {number} displayHeight - Rendered image height in CSS 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 }} @@ -26,8 +30,8 @@ export function computeScale(displayWidth, displayHeight, pageWidth, 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 - Scale factors from computeScale + * @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) { diff --git a/frontend/src/features/analysis/bboxScaling.test.js b/frontend/src/features/analysis/bboxScaling.test.js index 077d01a..24987be 100644 --- a/frontend/src/features/analysis/bboxScaling.test.js +++ b/frontend/src/features/analysis/bboxScaling.test.js @@ -13,6 +13,15 @@ describe('computeScale', () => { 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', () => { @@ -36,6 +45,15 @@ describe('bboxToRect', () => { 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', () => { 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..cf55588 100644 --- a/frontend/src/features/analysis/ui/ResultTabs.vue +++ b/frontend/src/features/analysis/ui/ResultTabs.vue @@ -13,11 +13,6 @@
-
{{ store.currentAnalysis.contentMarkdown }}
@@ -44,7 +39,6 @@ import { ref } from 'vue' import { useAnalysisStore } from '../store.js' import MarkdownViewer from './MarkdownViewer.vue' -import StructureViewer from './StructureViewer.vue' import ImageGallery from './ImageGallery.vue' const store = useAnalysisStore() @@ -52,7 +46,6 @@ const activeTab = ref('text') const tabs = [ { id: 'text', label: 'Résultat du texte' }, - { id: 'visual', label: 'Visuel' }, { id: 'markdown', label: 'Markdown' }, { id: 'images', label: 'Images' } ] diff --git a/frontend/src/pages/StudioPage.vue b/frontend/src/pages/StudioPage.vue index f716078..962f777 100644 --- a/frontend/src/pages/StudioPage.vue +++ b/frontend/src/pages/StudioPage.vue @@ -100,14 +100,35 @@ 100% +
- +
+ + +
@@ -189,11 +210,12 @@