From 4c6aff3029b871a3ce471badf03c1b0d41a7e9a0 Mon Sep 17 00:00:00 2001 From: pjmalandrino Date: Tue, 17 Mar 2026 13:33:36 +0100 Subject: [PATCH] Work on full Docker integration --- .env.example | 17 + .gitignore | 12 + LICENSE | 21 ++ README.md | 152 +++++++- backend/pom.xml | 5 + .../studio/analysis/AnalysisController.java | 6 +- .../studio/analysis/AnalysisRunner.java | 78 +++++ .../studio/analysis/AnalysisService.java | 54 +-- .../com/docling/studio/config/WebConfig.java | 6 +- .../studio/document/DocumentParserClient.java | 50 ++- .../studio/document/DocumentService.java | 19 + backend/src/main/resources/application.yml | 12 +- docker-compose.dev.yml | 8 +- docker-compose.yml | 21 +- document-parser/.dockerignore | 12 + document-parser/Dockerfile | 2 + document-parser/main.py | 330 +++++++++++++----- document-parser/requirements.txt | 13 +- .../src/features/analysis/ui/BboxOverlay.vue | 2 + .../src/features/analysis/ui/ResultTabs.vue | 20 +- 20 files changed, 653 insertions(+), 187 deletions(-) create mode 100644 .env.example create mode 100644 LICENSE create mode 100644 backend/src/main/java/com/docling/studio/analysis/AnalysisRunner.java create mode 100644 document-parser/.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..df0b3fe --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# PostgreSQL +POSTGRES_USER=app +POSTGRES_PASSWORD=changeme +POSTGRES_DB=docling_studio + +# Backend (Spring Boot) +SPRING_DATASOURCE_USERNAME=app +SPRING_DATASOURCE_PASSWORD=changeme + +# Document parser URL (only change if running parser on a different host) +# APP_DOCUMENT-PARSER_BASE-URL=http://document-parser:8000 + +# CORS (comma-separated origins, only needed for custom deployments) +# APP_CORS_ALLOWED_ORIGINS=http://localhost:3000,https://your-domain.com + +# File storage path (inside backend container) +# APP_STORAGE_PATH=./uploads diff --git a/.gitignore b/.gitignore index c256853..ccc1bc8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,11 +18,23 @@ Thumbs.db # Env .env .env.local +.env.production # Uploads uploads/ +backend/uploads/ # Python __pycache__/ *.pyc .venv/ +*.egg-info/ + +# Java +*.class +*.jar +*.log +hs_err_pid* + +# Docker +docker-compose.override.yml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5b3bab8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Pier-Jean Malandrino + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e0d4a42..ec14cce 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,175 @@ # Docling Studio -A professional document analysis studio powered by [Docling](https://github.com/DS4SD/docling), inspired by MistralAI Studio. +A visual document analysis studio powered by [Docling](https://github.com/DS4SD/docling). +Upload a PDF, configure the extraction pipeline, and visualize the results — text, tables, images, formulas, bounding boxes — all from your browser. + +![Docling Studio — Visual Mode](docs/screenshots/visual-mode.png) + +## Features + +- **PDF viewer** with page navigation and visual overlay toggle +- **Configurable Docling pipeline** — OCR on/off, table extraction mode (fast/accurate) +- **Bounding box visualization** — overlay extracted elements directly on the PDF with color-coded types +- **Per-page results** — right panel syncs with the current PDF page +- **Document hierarchy** — heading levels and structure preserved from Docling's `iterate_items()` API +- **Markdown & HTML export** of extracted content +- **Analysis history** — re-visit past analyses + +
+More screenshots + +| Import | Configure | Results | +|--------|-----------|---------| +| ![Import](docs/screenshots/import.png) | ![Configure](docs/screenshots/configure.png) | ![Results](docs/screenshots/results.png) | + +
## Architecture ``` -frontend/ → Vue 3 + Vite + Pinia (port 3000) -backend/ → Spring Boot 3.3.5 / Java 21 (port 8081) -document-parser/ → FastAPI + Docling (port 8000) +┌────────────┐ ┌──────────────┐ ┌──────────────────┐ +│ Frontend │────▶│ Backend │────▶│ Document Parser │ +│ Vue 3 │ │ Spring Boot │ │ FastAPI + Docling │ +│ port 3000 │ │ port 8081 │ │ port 8000 │ +└────────────┘ └──────┬───────┘ └──────────────────┘ + │ + ┌──────▼───────┐ + │ PostgreSQL │ + │ port 5432 │ + └──────────────┘ ``` +| Service | Stack | Role | +|---------|-------|------| +| **frontend** | Vue 3, Vite, Pinia | UI, PDF viewer, results display | +| **backend** | Spring Boot 3.3, Java 21, Liquibase | REST API, storage, orchestration | +| **document-parser** | FastAPI, Docling, pdf2image | PDF parsing with configurable pipeline | +| **postgres** | PostgreSQL 16 | Documents & analysis persistence | + ## Quick Start ### Docker Compose (recommended) ```bash -docker-compose up --build +# Clone the repo +git clone https://github.com/pjmalandrino/docling-studio.git +cd docling-studio + +# (Optional) customize credentials +cp .env.example .env + +# Start all services +docker compose up --build ``` Open [http://localhost:3000](http://localhost:3000) +> **Note:** First analysis may take a few minutes as Docling downloads its ML models (~40 MB) on first run. + ### Local Development -**Document Parser:** +Start only PostgreSQL via Docker: + +```bash +docker compose -f docker-compose.dev.yml up -d +``` + +Then run each service locally: + +**Document Parser** (Python 3.12+): ```bash cd document-parser +python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt uvicorn main:app --reload --port 8000 ``` -**Backend:** +**Backend** (Java 21+): ```bash cd backend ./mvnw spring-boot:run ``` -**Frontend:** +**Frontend** (Node 20+): ```bash cd frontend npm install npm run dev ``` -## Features +## Docling Integration -- PDF upload and document analysis via Docling -- Extracted content viewing (Markdown, HTML) -- Document structure visualization with bounding boxes -- Image detection -- Analysis history +The document parser wraps [Docling](https://github.com/DS4SD/docling) with configurable pipeline options exposed as query parameters on the `/parse` endpoint: + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `do_ocr` | `true` | Enable OCR for scanned documents | +| `do_table_structure` | `true` | Enable table structure extraction | +| `table_mode` | `accurate` | Table extraction mode: `accurate` or `fast` | + +Element types are detected using `isinstance()` checks against Docling's type hierarchy (`TextItem`, `TableItem`, `PictureItem`, `SectionHeaderItem`, etc.) and the document tree depth from `iterate_items()` is preserved for heading-level reconstruction. + +## Configuration + +All configuration is done via environment variables. See [`.env.example`](.env.example) for available options. + +| Variable | Default | Description | +|----------|---------|-------------| +| `POSTGRES_USER` | `app` | Database user | +| `POSTGRES_PASSWORD` | `app` | Database password | +| `POSTGRES_DB` | `docling_studio` | Database name | +| `APP_CORS_ALLOWED_ORIGINS` | `http://localhost:3000,...` | CORS allowed origins (comma-separated) | +| `APP_DOCUMENT-PARSER_BASE-URL` | `http://localhost:8000` | Document parser URL | +| `APP_STORAGE_PATH` | `./uploads` | File storage directory | + +## Performance & System Requirements + +Docling runs ML models (layout analysis, OCR, table structure) on **CPU by default**. Processing time depends on document size and complexity. + +| Document type | Pages | Approx. time (CPU) | +|---------------|-------|---------------------| +| Simple report | 5-10 | 1-3 min | +| Research paper | 15-30 | 5-10 min | +| Dense PDF with tables | 30+ | 10-20 min | + +### Docker Desktop settings + +The document parser needs **at least 4 GB of RAM**. Recommended Docker Desktop allocation: + +| Resource | Minimum | Recommended | +|----------|---------|-------------| +| Memory | 6 GB | 8 GB+ | +| CPUs | 4 | 8+ | + +> On **macOS**: Docker Desktop > Settings > Resources +> On **Windows**: Docker Desktop > Settings > Resources > WSL 2 + +### Platform support + +All Docker images are **multi-arch** (linux/amd64 + linux/arm64). Works natively on: + +| Platform | Architecture | GPU acceleration | +|----------|-------------|-----------------| +| **macOS Apple Silicon** (M1/M2/M3) | arm64 | Not in Docker (MPS unavailable). Run parser locally for GPU. | +| **macOS Intel** | amd64 | N/A | +| **Linux x86_64** | amd64 | NVIDIA GPU via `docker compose --profile gpu` (coming soon) | +| **Linux ARM** (Raspberry Pi 5, Ampere) | arm64 | CPU only | +| **Windows + WSL2** | amd64 | NVIDIA GPU passthrough supported | + +> **Tip for Mac users:** For faster processing, run the document parser **locally** (outside Docker) to leverage Apple Silicon's MPS acceleration when supported by PyTorch/Docling. + +## Tech Stack + +- **Frontend**: Vue 3 + Vite + Pinia +- **Backend**: Spring Boot 3.3 + Java 21 + Liquibase + PDFBox +- **Parser**: FastAPI + Docling 2.x + PyTorch + pdf2image +- **Database**: PostgreSQL 16 +- **Infra**: Docker Compose + Nginx + +## Contributing + +Contributions are welcome! Please open an issue first to discuss what you'd like to change. + +## License + +[MIT](LICENSE) — Pier-Jean Malandrino diff --git a/backend/pom.xml b/backend/pom.xml index 6303db3..5fa447f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -47,6 +47,11 @@ org.liquibase liquibase-core + + org.apache.pdfbox + pdfbox + 3.0.3 + org.springframework.boot diff --git a/backend/src/main/java/com/docling/studio/analysis/AnalysisController.java b/backend/src/main/java/com/docling/studio/analysis/AnalysisController.java index 0dc25ff..7769273 100644 --- a/backend/src/main/java/com/docling/studio/analysis/AnalysisController.java +++ b/backend/src/main/java/com/docling/studio/analysis/AnalysisController.java @@ -20,7 +20,11 @@ public class AnalysisController { @PostMapping public AnalysisResponse create(@RequestBody Map body) { - UUID documentId = UUID.fromString(body.get("documentId")); + String raw = body.get("documentId"); + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException("documentId is required"); + } + UUID documentId = UUID.fromString(raw); AnalysisJob job = service.create(documentId); return AnalysisResponse.from(job); } diff --git a/backend/src/main/java/com/docling/studio/analysis/AnalysisRunner.java b/backend/src/main/java/com/docling/studio/analysis/AnalysisRunner.java new file mode 100644 index 0000000..4861eef --- /dev/null +++ b/backend/src/main/java/com/docling/studio/analysis/AnalysisRunner.java @@ -0,0 +1,78 @@ +package com.docling.studio.analysis; + +import com.docling.studio.document.Document; +import com.docling.studio.document.DocumentParserClient; +import com.docling.studio.document.DocumentService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; + +/** + * Separated from AnalysisService so that Spring's @Async proxy works correctly. + * (Self-invocation within the same class bypasses the AOP proxy.) + */ +@Component +public class AnalysisRunner { + + private static final Logger log = LoggerFactory.getLogger(AnalysisRunner.class); + + private final AnalysisJobRepository repository; + private final DocumentService documentService; + private final DocumentParserClient parserClient; + private final ObjectMapper objectMapper; + + public AnalysisRunner( + AnalysisJobRepository repository, + DocumentService documentService, + DocumentParserClient parserClient, + ObjectMapper objectMapper + ) { + this.repository = repository; + this.documentService = documentService; + this.parserClient = parserClient; + this.objectMapper = objectMapper; + } + + @Async("analysisExecutor") + public void runAnalysis(UUID jobId) { + AnalysisJob job = repository.findById(jobId).orElseThrow(); + job.markRunning(); + repository.save(job); + + log.info("Starting analysis for document: {}", job.getDocument().getFilename()); + + try { + Path filePath = documentService.getFilePath(job.getDocument().getId()); + Map result = parserClient.parse(filePath, job.getDocument().getFilename()); + + String markdown = (String) result.getOrDefault("content_markdown", ""); + String html = (String) result.getOrDefault("content_html", ""); + Object pages = result.get("pages"); + String pagesJson = pages != null ? objectMapper.writeValueAsString(pages) : "[]"; + + // Update page count on document if available + Object pageCount = result.get("page_count"); + if (pageCount instanceof Number n && n.intValue() > 0) { + Document doc = job.getDocument(); + doc.setPageCount(n.intValue()); + documentService.save(doc); + } + + job.markCompleted(markdown, html, pagesJson); + repository.save(job); + + log.info("Analysis completed for document: {}", job.getDocument().getFilename()); + + } catch (Exception e) { + log.error("Analysis failed for document: {}", job.getDocument().getFilename(), e); + job.markFailed(e.getMessage()); + repository.save(job); + } + } +} 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 133b3ba..7001def 100644 --- a/backend/src/main/java/com/docling/studio/analysis/AnalysisService.java +++ b/backend/src/main/java/com/docling/studio/analysis/AnalysisService.java @@ -1,16 +1,12 @@ package com.docling.studio.analysis; import com.docling.studio.document.Document; -import com.docling.studio.document.DocumentParserClient; import com.docling.studio.document.DocumentService; import com.docling.studio.shared.exception.ResourceNotFoundException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.nio.file.Path; import java.util.List; -import java.util.Map; import java.util.UUID; @Service @@ -18,70 +14,40 @@ public class AnalysisService { private final AnalysisJobRepository repository; private final DocumentService documentService; - private final DocumentParserClient parserClient; - private final ObjectMapper objectMapper; + private final AnalysisRunner runner; public AnalysisService( AnalysisJobRepository repository, DocumentService documentService, - DocumentParserClient parserClient, - ObjectMapper objectMapper + AnalysisRunner runner ) { this.repository = repository; this.documentService = documentService; - this.parserClient = parserClient; - this.objectMapper = objectMapper; + this.runner = runner; } + @Transactional public AnalysisJob create(UUID documentId) { Document doc = documentService.findById(documentId); AnalysisJob job = new AnalysisJob(doc); repository.save(job); - runAnalysis(job.getId()); + // Delegate to a separate bean so @Async proxy works (no self-invocation) + runner.runAnalysis(job.getId()); return job; } - @Async("analysisExecutor") - public void runAnalysis(UUID jobId) { - AnalysisJob job = repository.findById(jobId).orElseThrow(); - job.markRunning(); - repository.save(job); - - try { - Path filePath = documentService.getFilePath(job.getDocument().getId()); - Map result = parserClient.parse(filePath, job.getDocument().getFilename()); - - String markdown = (String) result.getOrDefault("content_markdown", ""); - String html = (String) result.getOrDefault("content_html", ""); - Object pages = result.get("pages"); - String pagesJson = pages != null ? objectMapper.writeValueAsString(pages) : "[]"; - - // Update page count on document if available - Object pageCount = result.get("page_count"); - if (pageCount instanceof Number n && n.intValue() > 0) { - Document doc = job.getDocument(); - doc.setPageCount(n.intValue()); - documentService.save(doc); - } - - job.markCompleted(markdown, html, pagesJson); - repository.save(job); - - } catch (Exception e) { - job.markFailed(e.getMessage()); - repository.save(job); - } - } - + @Transactional(readOnly = true) public AnalysisJob findById(UUID id) { return repository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Analysis not found: " + id)); } + @Transactional(readOnly = true) public List findAll() { return repository.findAllByOrderByCreatedAtDesc(); } + @Transactional public void delete(UUID id) { AnalysisJob job = findById(id); repository.delete(job); diff --git a/backend/src/main/java/com/docling/studio/config/WebConfig.java b/backend/src/main/java/com/docling/studio/config/WebConfig.java index a6a3113..0d75d39 100644 --- a/backend/src/main/java/com/docling/studio/config/WebConfig.java +++ b/backend/src/main/java/com/docling/studio/config/WebConfig.java @@ -1,5 +1,6 @@ package com.docling.studio.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -7,10 +8,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { + @Value("${app.cors.allowed-origins:http://localhost:3000,http://localhost:5173}") + private String allowedOrigins; + @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") - .allowedOrigins("http://localhost:3000", "http://localhost:5173") + .allowedOrigins(allowedOrigins.split(",")) .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true); diff --git a/backend/src/main/java/com/docling/studio/document/DocumentParserClient.java b/backend/src/main/java/com/docling/studio/document/DocumentParserClient.java index 8dbed2e..1db79c4 100644 --- a/backend/src/main/java/com/docling/studio/document/DocumentParserClient.java +++ b/backend/src/main/java/com/docling/studio/document/DocumentParserClient.java @@ -1,19 +1,26 @@ package com.docling.studio.document; import com.docling.studio.config.DocumentParserProperties; +import com.docling.studio.shared.exception.ServiceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.core.io.FileSystemResource; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; import java.nio.file.Path; +import java.time.Duration; import java.util.Map; @Component public class DocumentParserClient { + private static final Logger log = LoggerFactory.getLogger(DocumentParserClient.class); + private final WebClient webClient; public DocumentParserClient(DocumentParserProperties props) { @@ -30,12 +37,26 @@ public class DocumentParserClient { .filename(filename) .contentType(MediaType.APPLICATION_PDF); - return webClient.post() - .uri("/parse") - .body(BodyInserters.fromMultipartData(builder.build())) - .retrieve() - .bodyToMono(Map.class) - .block(); + try { + return webClient.post() + .uri("/parse") + .body(BodyInserters.fromMultipartData(builder.build())) + .retrieve() + .bodyToMono(Map.class) + .block(Duration.ofMinutes(10)); + } catch (WebClientResponseException e) { + log.error("Parser returned HTTP {}: {}", e.getStatusCode().value(), e.getResponseBodyAsString()); + throw new ServiceException("Document parser error: " + extractDetail(e)); + } catch (java.lang.IllegalStateException e) { + if (e.getMessage() != null && e.getMessage().contains("Timeout")) { + log.error("Parser timed out after 10 minutes for file: {}", filename); + throw new ServiceException("Document parsing timed out. The document may be too large or complex."); + } + throw new ServiceException("Document parser error: " + e.getMessage()); + } catch (Exception e) { + log.error("Failed to reach document parser", e); + throw new ServiceException("Document parser unavailable: " + e.getMessage()); + } } public byte[] preview(Path filePath, String filename, int page, int dpi) { @@ -53,9 +74,24 @@ public class DocumentParserClient { .body(BodyInserters.fromMultipartData(builder.build())) .retrieve() .bodyToMono(byte[].class) - .block(); + .block(Duration.ofSeconds(30)); } catch (Exception e) { + log.warn("Preview generation failed for page {}: {}", page, e.getMessage()); return null; } } + + /** Extract the "detail" field from a FastAPI error response, or fall back to the raw body. */ + private String extractDetail(WebClientResponseException e) { + String body = e.getResponseBodyAsString(); + try { + var tree = new com.fasterxml.jackson.databind.ObjectMapper().readTree(body); + if (tree.has("detail")) { + return tree.get("detail").asText(); + } + } catch (Exception ignored) { + // Not valid JSON — fall through to raw body + } + return body.length() > 200 ? body.substring(0, 200) : body; + } } 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 ef6073d..400dfa1 100644 --- a/backend/src/main/java/com/docling/studio/document/DocumentService.java +++ b/backend/src/main/java/com/docling/studio/document/DocumentService.java @@ -2,8 +2,13 @@ package com.docling.studio.document; import com.docling.studio.shared.exception.ResourceNotFoundException; import com.docling.studio.shared.exception.ServiceException; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; @@ -15,6 +20,8 @@ import java.util.UUID; @Service public class DocumentService { + private static final Logger log = LoggerFactory.getLogger(DocumentService.class); + private final DocumentRepository repository; private final DocumentParserClient parserClient; private final Path storagePath; @@ -29,6 +36,7 @@ public class DocumentService { this.storagePath = Path.of(storagePath); } + @Transactional public Document upload(MultipartFile file) { if (file.isEmpty() || file.getOriginalFilename() == null) { throw new IllegalArgumentException("File is empty or has no name"); @@ -46,6 +54,7 @@ public class DocumentService { file.getSize(), target.toString() ); + doc.setPageCount(countPages(target)); return repository.save(doc); } catch (IOException e) { @@ -66,6 +75,7 @@ public class DocumentService { .orElseThrow(() -> new ResourceNotFoundException("Document not found: " + id)); } + @Transactional public void delete(UUID id) { Document doc = findById(id); try { @@ -85,4 +95,13 @@ public class DocumentService { Document doc = findById(id); return Path.of(doc.getStoragePath()); } + + private int countPages(Path pdfPath) { + try (PDDocument pdf = Loader.loadPDF(pdfPath.toFile())) { + return pdf.getNumberOfPages(); + } catch (Exception e) { + log.warn("Could not count pages for {}: {}", pdfPath.getFileName(), e.getMessage()); + return 0; + } + } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index a7e3374..e1e49dc 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -3,9 +3,9 @@ server: spring: datasource: - url: jdbc:postgresql://localhost:5432/docling_studio - username: app - password: app + url: jdbc:postgresql://${DB_HOST:localhost}:5432/${DB_NAME:docling_studio} + username: ${SPRING_DATASOURCE_USERNAME:app} + password: ${SPRING_DATASOURCE_PASSWORD:app} jpa: hibernate: ddl-auto: validate @@ -19,6 +19,8 @@ spring: app: document-parser: - base-url: http://localhost:8000 + base-url: ${APP_DOCUMENT-PARSER_BASE-URL:http://localhost:8000} storage: - path: ./uploads + path: ${APP_STORAGE_PATH:./uploads} + cors: + allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 622529a..4294634 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,15 +2,15 @@ services: postgres: image: postgres:16-alpine environment: - POSTGRES_USER: app - POSTGRES_PASSWORD: app - POSTGRES_DB: docling_studio + POSTGRES_USER: ${POSTGRES_USER:-app} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app} + POSTGRES_DB: ${POSTGRES_DB:-docling_studio} ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U app -d docling_studio"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-docling_studio}"] interval: 5s timeout: 5s retries: 10 diff --git a/docker-compose.yml b/docker-compose.yml index 990e20c..613a90c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,15 +2,15 @@ services: postgres: image: postgres:16-alpine environment: - POSTGRES_USER: app - POSTGRES_PASSWORD: app - POSTGRES_DB: docling_studio + POSTGRES_USER: ${POSTGRES_USER:-app} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app} + POSTGRES_DB: ${POSTGRES_DB:-docling_studio} ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U app -d docling_studio"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-docling_studio}"] interval: 5s timeout: 5s retries: 10 @@ -20,6 +20,10 @@ services: context: ./document-parser ports: - "8000:8000" + deploy: + resources: + limits: + memory: 4g backend: build: @@ -27,10 +31,12 @@ services: ports: - "8081:8081" environment: - SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/docling_studio - SPRING_DATASOURCE_USERNAME: app - SPRING_DATASOURCE_PASSWORD: app + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-docling_studio} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-app} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-app} APP_DOCUMENT-PARSER_BASE-URL: http://document-parser:8000 + volumes: + - uploads_data:/app/uploads depends_on: postgres: condition: service_healthy @@ -45,3 +51,4 @@ services: volumes: postgres_data: + uploads_data: diff --git a/document-parser/.dockerignore b/document-parser/.dockerignore new file mode 100644 index 0000000..643c3a7 --- /dev/null +++ b/document-parser/.dockerignore @@ -0,0 +1,12 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.git/ +.gitignore +.env +*.log +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ diff --git a/document-parser/Dockerfile b/document-parser/Dockerfile index b047eee..a58d6be 100644 --- a/document-parser/Dockerfile +++ b/document-parser/Dockerfile @@ -2,6 +2,8 @@ FROM python:3.12-slim RUN apt-get update && apt-get install -y --no-install-recommends \ poppler-utils \ + libgl1 \ + libglib2.0-0 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/document-parser/main.py b/document-parser/main.py index efa66bf..35f91e8 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -1,33 +1,114 @@ +"""Docling Studio — Document Parser service. + +A FastAPI microservice wrapping the Docling library for structured document +extraction. Provides parse (full extraction) and preview (page image) endpoints +with configurable pipeline options. +""" + import io import logging import os import tempfile +import threading from pathlib import Path +from typing import Optional from fastapi import FastAPI, UploadFile, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel -from docling.document_converter import DocumentConverter from pdf2image import convert_from_bytes -from PIL import Image + +from docling.document_converter import DocumentConverter, PdfFormatOption +from docling.datamodel.base_models import InputFormat +from docling.datamodel.pipeline_options import ( + PdfPipelineOptions, + TableFormerMode, + TableStructureOptions, +) +from docling_core.types.doc import ( + CodeItem, + FloatingItem, + FormulaItem, + GroupItem, + ListItem, + PictureItem, + SectionHeaderItem, + TableItem, + TextItem, + TitleItem, +) from bbox import to_topleft_list logger = logging.getLogger(__name__) -app = FastAPI(title="Docling Studio - Document Parser") - -converter = DocumentConverter() +app = FastAPI(title="Docling Studio — Document Parser") MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB +# Thread lock for converter — DocumentConverter is not thread-safe +_converter_lock = threading.Lock() -# --- Response models --- + +# --------------------------------------------------------------------------- +# Pipeline factory +# --------------------------------------------------------------------------- + +def _build_converter( + do_ocr: bool = True, + do_table_structure: bool = True, + table_mode: str = "accurate", +) -> DocumentConverter: + """Build a DocumentConverter with the given pipeline options. + + Only exposes options that work out of the box (no extra model downloads). + """ + table_options = TableStructureOptions( + do_cell_matching=True, + mode=TableFormerMode.ACCURATE if table_mode == "accurate" else TableFormerMode.FAST, + ) + + pipeline_options = PdfPipelineOptions( + do_ocr=do_ocr, + do_table_structure=do_table_structure, + table_structure_options=table_options, + # These require VLM model downloads — disabled by default + do_code_enrichment=False, + do_formula_enrichment=False, + do_picture_classification=False, + do_picture_description=False, + # Page images are handled by pdf2image in /preview, not needed here + generate_page_images=False, + generate_picture_images=False, + ) + + return DocumentConverter( + format_options={ + InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options), + } + ) + + +# Default converter (lazy-init on first request) +_default_converter: Optional[DocumentConverter] = None + + +def _get_default_converter() -> DocumentConverter: + global _default_converter + if _default_converter is None: + _default_converter = _build_converter() + return _default_converter + + +# --------------------------------------------------------------------------- +# Response models +# --------------------------------------------------------------------------- class PageElement(BaseModel): type: str bbox: list[float] content: str + level: int = 0 # Hierarchy depth from iterate_items() class PageDetail(BaseModel): @@ -43,56 +124,94 @@ class ParseResponse(BaseModel): content_markdown: str content_html: str pages: list[PageDetail] + skipped_items: int = 0 # Transparency on extraction failures -# --- Helpers --- +# --------------------------------------------------------------------------- +# Element type detection — isinstance-based (no string matching) +# --------------------------------------------------------------------------- -def extract_pages_detail(doc_result) -> list[PageDetail]: +def _get_element_type(item) -> str: + """Determine the element type using isinstance checks on Docling classes. + + Order matters: more specific types are checked before their parent classes. + """ + if isinstance(item, TableItem): + return "table" + if isinstance(item, PictureItem): + return "picture" + if isinstance(item, TitleItem): + return "title" + if isinstance(item, SectionHeaderItem): + return "section_header" + if isinstance(item, ListItem): + return "list" + if isinstance(item, FormulaItem): + return "formula" + if isinstance(item, CodeItem): + return "code" + if isinstance(item, FloatingItem): + return "floating" + if isinstance(item, GroupItem): + return "group" + if isinstance(item, TextItem): + return "text" + return "text" + + +# --------------------------------------------------------------------------- +# Page extraction +# --------------------------------------------------------------------------- + +def extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]: """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. + Returns (pages, skipped_count) for transparent error reporting. """ pages: dict[int, PageDetail] = {} document = doc_result.document + skipped = 0 - # Get page dimensions from document pages - if hasattr(document, 'pages') and document.pages: + # Populate page dimensions from document metadata + if hasattr(document, "pages") and document.pages: for page_key, page_obj in document.pages.items(): page_no = int(page_key) if isinstance(page_key, str) else page_key - width = page_obj.size.width if hasattr(page_obj, 'size') and page_obj.size else 612.0 - height = page_obj.size.height if hasattr(page_obj, 'size') and page_obj.size else 792.0 + width = page_obj.size.width if hasattr(page_obj, "size") and page_obj.size else 612.0 + height = page_obj.size.height if hasattr(page_obj, "size") and page_obj.size else 792.0 pages[page_no] = PageDetail( - page_number=page_no, - width=width, - height=height, - elements=[] + page_number=page_no, width=width, height=height, elements=[] ) - # 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'): + # Use iterate_items() — the Docling v2 API that yields (item, level) + if hasattr(document, "iterate_items"): + for item, level in document.iterate_items(): + ok = _process_content_item(item, level, pages) + if not ok: + skipped += 1 + elif hasattr(document, "texts"): for text_item in document.texts: - _process_content_item(text_item, pages) + ok = _process_content_item(text_item, 0, pages) + if not ok: + skipped += 1 - # Sort by page number - return sorted(pages.values(), key=lambda p: p.page_number) + sorted_pages = sorted(pages.values(), key=lambda p: p.page_number) + return sorted_pages, skipped -def _process_content_item(item, pages: dict[int, PageDetail]): +def _process_content_item(item, level: int, pages: dict[int, PageDetail]) -> bool: """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. + Returns True on success, False if the item was skipped. """ - if not hasattr(item, 'prov') or not item.prov: - return + # Skip groups and items without provenance + if isinstance(item, GroupItem): + return True # Groups are structural, not content — not an error + if not hasattr(item, "prov") or not item.prov: + return False for prov in item.prov: try: - page_no = prov.page_no if hasattr(prov, 'page_no') else 1 + page_no = prov.page_no if hasattr(prov, "page_no") else 1 if page_no not in pages: pages[page_no] = PageDetail( @@ -101,108 +220,129 @@ def _process_content_item(item, pages: dict[int, PageDetail]): page_height = pages[page_no].height - bbox = [0, 0, 0, 0] - if hasattr(prov, 'bbox') and prov.bbox: + bbox = [0.0, 0.0, 0.0, 0.0] + if hasattr(prov, "bbox") and prov.bbox: b = prov.bbox - if hasattr(b, 'l'): + 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'): + if hasattr(item, "text"): content = item.text or "" + # For tables, try to export structured content + if isinstance(item, TableItem) and hasattr(item, "export_to_markdown"): + try: + content = item.export_to_markdown() + except Exception: + pass # Fall back to .text - pages[page_no].elements.append(PageElement( - type=element_type, - bbox=bbox, - content=content[:500] - )) + pages[page_no].elements.append( + PageElement( + type=element_type, + bbox=bbox, + content=content, + level=level, + ) + ) except Exception: - logger.warning("Skipping item %s: failed to process", type(item).__name__, exc_info=True) + logger.warning( + "Skipping item %s on page %s", + type(item).__name__, + getattr(prov, "page_no", "?"), + exc_info=True, + ) + return False + + return True - -def _get_element_type(item) -> str: - """Determine the element type from a Docling document item.""" - type_name = type(item).__name__.lower() - if 'table' in type_name: - return 'table' - if 'picture' in type_name or 'image' in type_name or 'figure' in type_name: - return 'picture' - if 'section' in type_name or 'heading' in type_name: - return 'section_header' - if 'list' in type_name: - return 'list' - if 'formula' in type_name or 'equation' in type_name: - return 'formula' - if 'caption' in type_name: - return 'caption' - if hasattr(item, 'label'): - label = str(item.label).lower() - if 'table' in label: - return 'table' - if 'picture' in label or 'figure' in label: - return 'picture' - if 'section' in label or 'head' in label: - return 'section_header' - if 'list' in label: - return 'list' - if 'formula' in label: - return 'formula' - return 'text' - - -# --- Endpoints --- +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- @app.post("/parse", response_model=ParseResponse) -async def parse(file: UploadFile): +async def parse( + file: UploadFile, + do_ocr: bool = Query(True, description="Enable OCR for scanned documents"), + do_table_structure: bool = Query(True, description="Enable table structure extraction"), + table_mode: str = Query("accurate", regex="^(accurate|fast)$", description="Table extraction mode"), +): + """Parse a document and return structured content with per-page elements.""" if not file.filename: raise HTTPException(status_code=400, detail="No filename provided") - content = await file.read() - if len(content) > MAX_FILE_SIZE: - raise HTTPException(status_code=413, detail="File too large (max 50MB)") + file_content = await file.read() + if len(file_content) > MAX_FILE_SIZE: + raise HTTPException(status_code=413, detail="File too large (max 50 MB)") suffix = Path(file.filename).suffix tmp_path = None try: with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: - tmp.write(content) + tmp.write(file_content) tmp_path = tmp.name - result = converter.convert(tmp_path) + # Build converter with requested options (or reuse default) + if do_ocr and do_table_structure and table_mode == "accurate": + conv = _get_default_converter() + else: + conv = _build_converter( + do_ocr=do_ocr, + do_table_structure=do_table_structure, + table_mode=table_mode, + ) + + with _converter_lock: + result = conv.convert(tmp_path) + doc = result.document content_markdown = doc.export_to_markdown() - content_html = doc.export_to_html() if hasattr(doc, 'export_to_html') else "" + content_html = doc.export_to_html() if hasattr(doc, "export_to_html") else "" - page_count = 0 - if hasattr(doc, 'pages') and doc.pages: - page_count = len(doc.pages) + page_count = len(doc.pages) if hasattr(doc, "pages") and doc.pages else 0 - pages_detail = extract_pages_detail(result) + pages_detail, skipped = extract_pages_detail(result) + + # Ensure we have page entries even if no elements were extracted if not pages_detail and page_count > 0: pages_detail = [ PageDetail(page_number=i + 1, width=612.0, height=792.0, elements=[]) for i in range(page_count) ] + if skipped > 0: + logger.info( + "Parsed %s: %d pages, %d items skipped", + file.filename, + page_count, + skipped, + ) + return ParseResponse( filename=file.filename, page_count=page_count or len(pages_detail) or 1, content_markdown=content_markdown, content_html=content_html, pages=pages_detail, + skipped_items=skipped, ) + except HTTPException: + raise 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)}") + raise HTTPException(status_code=422, detail=f"Failed to parse document: {e}") finally: if tmp_path and os.path.exists(tmp_path): - os.unlink(tmp_path) + try: + os.unlink(tmp_path) + except OSError: + logger.warning("Could not delete temp file: %s", tmp_path) @app.post("/preview") @@ -211,16 +351,16 @@ async def preview( page: int = Query(1, ge=1), dpi: int = Query(150, ge=72, le=300), ): - """Generate a PNG preview of a specific page.""" + """Generate a PNG preview of a specific PDF page.""" if not file.filename: raise HTTPException(status_code=400, detail="No filename provided") - content = await file.read() - if len(content) > MAX_FILE_SIZE: - raise HTTPException(status_code=413, detail="File too large (max 50MB)") + file_content = await file.read() + if len(file_content) > MAX_FILE_SIZE: + raise HTTPException(status_code=413, detail="File too large (max 50 MB)") try: - images = convert_from_bytes(content, first_page=page, last_page=page, dpi=dpi) + images = convert_from_bytes(file_content, first_page=page, last_page=page, dpi=dpi) if not images: raise HTTPException(status_code=404, detail=f"Page {page} not found") @@ -229,10 +369,14 @@ async def preview( buf.seek(0) return StreamingResponse(buf, media_type="image/png") + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=422, detail=f"Failed to generate preview: {str(e)}") + logger.exception("Failed to generate preview for page %d", page) + raise HTTPException(status_code=422, detail=f"Failed to generate preview: {e}") @app.get("/health") def health(): + """Health check endpoint.""" return {"status": "ok"} diff --git a/document-parser/requirements.txt b/document-parser/requirements.txt index e1a213d..a9eb762 100644 --- a/document-parser/requirements.txt +++ b/document-parser/requirements.txt @@ -1,6 +1,7 @@ -docling -fastapi -uvicorn[standard] -python-multipart -pdf2image -pillow +docling>=2.80.0,<3.0.0 +docling-core>=2.0.0,<3.0.0 +fastapi>=0.115.0,<1.0.0 +uvicorn[standard]>=0.32.0,<1.0.0 +python-multipart>=0.0.12 +pdf2image>=1.17.0,<2.0.0 +pillow>=10.0.0,<11.0.0 diff --git a/frontend/src/features/analysis/ui/BboxOverlay.vue b/frontend/src/features/analysis/ui/BboxOverlay.vue index 131dfca..a3badf7 100644 --- a/frontend/src/features/analysis/ui/BboxOverlay.vue +++ b/frontend/src/features/analysis/ui/BboxOverlay.vue @@ -40,12 +40,14 @@ import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } import { computeScale, bboxToRect, pointInRect } from '../bboxScaling.js' const ELEMENT_COLORS = { + title: '#EF4444', section_header: '#F97316', text: '#3B82F6', table: '#8B5CF6', picture: '#22C55E', list: '#06B6D4', formula: '#EC4899', + code: '#14B8A6', caption: '#EAB308' } diff --git a/frontend/src/features/analysis/ui/ResultTabs.vue b/frontend/src/features/analysis/ui/ResultTabs.vue index 98c4c8a..177262e 100644 --- a/frontend/src/features/analysis/ui/ResultTabs.vue +++ b/frontend/src/features/analysis/ui/ResultTabs.vue @@ -83,17 +83,27 @@ const pageMarkdown = computed(() => { function formatElement(el) { if (!el.content) return '' + const indent = ' '.repeat(Math.max(0, (el.level || 0) - 1)) switch (el.type) { - case 'section_header': - return `## ${el.content}` + case 'title': + return `# ${el.content}` + case 'section_header': { + // Use hierarchy level for heading depth (h2-h4) + const depth = Math.min(Math.max(el.level || 2, 2), 4) + return `${'#'.repeat(depth)} ${el.content}` + } case 'caption': - return `*${el.content}*` + return `${indent}*${el.content}*` case 'table': return el.content case 'formula': - return `$$${el.content}$$` + return `${indent}$$${el.content}$$` + case 'code': + return `${indent}\`\`\`\n${el.content}\n\`\`\`` + case 'list': + return `${indent}- ${el.content}` default: - return el.content + return `${indent}${el.content}` } }