Work on full Docker integration

This commit is contained in:
pjmalandrino 2026-03-17 13:33:36 +01:00
parent e76012f341
commit 4c6aff3029
20 changed files with 653 additions and 187 deletions

17
.env.example Normal file
View file

@ -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

12
.gitignore vendored
View file

@ -18,11 +18,23 @@ Thumbs.db
# Env # Env
.env .env
.env.local .env.local
.env.production
# Uploads # Uploads
uploads/ uploads/
backend/uploads/
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/ .venv/
*.egg-info/
# Java
*.class
*.jar
*.log
hs_err_pid*
# Docker
docker-compose.override.yml

21
LICENSE Normal file
View file

@ -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.

152
README.md
View file

@ -1,51 +1,175 @@
# Docling Studio # 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
<details>
<summary>More screenshots</summary>
| Import | Configure | Results |
|--------|-----------|---------|
| ![Import](docs/screenshots/import.png) | ![Configure](docs/screenshots/configure.png) | ![Results](docs/screenshots/results.png) |
</details>
## Architecture ## Architecture
``` ```
frontend/ → Vue 3 + Vite + Pinia (port 3000) ┌────────────┐ ┌──────────────┐ ┌──────────────────┐
backend/ → Spring Boot 3.3.5 / Java 21 (port 8081) │ Frontend │────▶│ Backend │────▶│ Document Parser │
document-parser/ → FastAPI + Docling (port 8000) │ 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 ## Quick Start
### Docker Compose (recommended) ### Docker Compose (recommended)
```bash ```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) 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 ### 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 ```bash
cd document-parser cd document-parser
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
uvicorn main:app --reload --port 8000 uvicorn main:app --reload --port 8000
``` ```
**Backend:** **Backend** (Java 21+):
```bash ```bash
cd backend cd backend
./mvnw spring-boot:run ./mvnw spring-boot:run
``` ```
**Frontend:** **Frontend** (Node 20+):
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev npm run dev
``` ```
## Features ## Docling Integration
- PDF upload and document analysis via Docling The document parser wraps [Docling](https://github.com/DS4SD/docling) with configurable pipeline options exposed as query parameters on the `/parse` endpoint:
- Extracted content viewing (Markdown, HTML)
- Document structure visualization with bounding boxes | Parameter | Default | Description |
- Image detection |-----------|---------|-------------|
- Analysis history | `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

View file

@ -47,6 +47,11 @@
<groupId>org.liquibase</groupId> <groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId> <artifactId>liquibase-core</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View file

@ -20,7 +20,11 @@ public class AnalysisController {
@PostMapping @PostMapping
public AnalysisResponse create(@RequestBody Map<String, String> body) { public AnalysisResponse create(@RequestBody Map<String, String> 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); AnalysisJob job = service.create(documentId);
return AnalysisResponse.from(job); return AnalysisResponse.from(job);
} }

View file

@ -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<String, Object> 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);
}
}
}

View file

@ -1,16 +1,12 @@
package com.docling.studio.analysis; package com.docling.studio.analysis;
import com.docling.studio.document.Document; import com.docling.studio.document.Document;
import com.docling.studio.document.DocumentParserClient;
import com.docling.studio.document.DocumentService; import com.docling.studio.document.DocumentService;
import com.docling.studio.shared.exception.ResourceNotFoundException; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
@Service @Service
@ -18,70 +14,40 @@ public class AnalysisService {
private final AnalysisJobRepository repository; private final AnalysisJobRepository repository;
private final DocumentService documentService; private final DocumentService documentService;
private final DocumentParserClient parserClient; private final AnalysisRunner runner;
private final ObjectMapper objectMapper;
public AnalysisService( public AnalysisService(
AnalysisJobRepository repository, AnalysisJobRepository repository,
DocumentService documentService, DocumentService documentService,
DocumentParserClient parserClient, AnalysisRunner runner
ObjectMapper objectMapper
) { ) {
this.repository = repository; this.repository = repository;
this.documentService = documentService; this.documentService = documentService;
this.parserClient = parserClient; this.runner = runner;
this.objectMapper = objectMapper;
} }
@Transactional
public AnalysisJob create(UUID documentId) { public AnalysisJob create(UUID documentId) {
Document doc = documentService.findById(documentId); Document doc = documentService.findById(documentId);
AnalysisJob job = new AnalysisJob(doc); AnalysisJob job = new AnalysisJob(doc);
repository.save(job); repository.save(job);
runAnalysis(job.getId()); // Delegate to a separate bean so @Async proxy works (no self-invocation)
runner.runAnalysis(job.getId());
return job; return job;
} }
@Async("analysisExecutor") @Transactional(readOnly = true)
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<String, Object> 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);
}
}
public AnalysisJob findById(UUID id) { public AnalysisJob findById(UUID id) {
return repository.findById(id) return repository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Analysis not found: " + id)); .orElseThrow(() -> new ResourceNotFoundException("Analysis not found: " + id));
} }
@Transactional(readOnly = true)
public List<AnalysisJob> findAll() { public List<AnalysisJob> findAll() {
return repository.findAllByOrderByCreatedAtDesc(); return repository.findAllByOrderByCreatedAtDesc();
} }
@Transactional
public void delete(UUID id) { public void delete(UUID id) {
AnalysisJob job = findById(id); AnalysisJob job = findById(id);
repository.delete(job); repository.delete(job);

View file

@ -1,5 +1,6 @@
package com.docling.studio.config; package com.docling.studio.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@ -7,10 +8,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @Configuration
public class WebConfig implements WebMvcConfigurer { public class WebConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origins:http://localhost:3000,http://localhost:5173}")
private String allowedOrigins;
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**") registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000", "http://localhost:5173") .allowedOrigins(allowedOrigins.split(","))
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*") .allowedHeaders("*")
.allowCredentials(true); .allowCredentials(true);

View file

@ -1,19 +1,26 @@
package com.docling.studio.document; package com.docling.studio.document;
import com.docling.studio.config.DocumentParserProperties; 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.core.io.FileSystemResource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration;
import java.util.Map; import java.util.Map;
@Component @Component
public class DocumentParserClient { public class DocumentParserClient {
private static final Logger log = LoggerFactory.getLogger(DocumentParserClient.class);
private final WebClient webClient; private final WebClient webClient;
public DocumentParserClient(DocumentParserProperties props) { public DocumentParserClient(DocumentParserProperties props) {
@ -30,12 +37,26 @@ public class DocumentParserClient {
.filename(filename) .filename(filename)
.contentType(MediaType.APPLICATION_PDF); .contentType(MediaType.APPLICATION_PDF);
try {
return webClient.post() return webClient.post()
.uri("/parse") .uri("/parse")
.body(BodyInserters.fromMultipartData(builder.build())) .body(BodyInserters.fromMultipartData(builder.build()))
.retrieve() .retrieve()
.bodyToMono(Map.class) .bodyToMono(Map.class)
.block(); .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) { public byte[] preview(Path filePath, String filename, int page, int dpi) {
@ -53,9 +74,24 @@ public class DocumentParserClient {
.body(BodyInserters.fromMultipartData(builder.build())) .body(BodyInserters.fromMultipartData(builder.build()))
.retrieve() .retrieve()
.bodyToMono(byte[].class) .bodyToMono(byte[].class)
.block(); .block(Duration.ofSeconds(30));
} catch (Exception e) { } catch (Exception e) {
log.warn("Preview generation failed for page {}: {}", page, e.getMessage());
return null; 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;
}
} }

View file

@ -2,8 +2,13 @@ package com.docling.studio.document;
import com.docling.studio.shared.exception.ResourceNotFoundException; import com.docling.studio.shared.exception.ResourceNotFoundException;
import com.docling.studio.shared.exception.ServiceException; 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.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
@ -15,6 +20,8 @@ import java.util.UUID;
@Service @Service
public class DocumentService { public class DocumentService {
private static final Logger log = LoggerFactory.getLogger(DocumentService.class);
private final DocumentRepository repository; private final DocumentRepository repository;
private final DocumentParserClient parserClient; private final DocumentParserClient parserClient;
private final Path storagePath; private final Path storagePath;
@ -29,6 +36,7 @@ public class DocumentService {
this.storagePath = Path.of(storagePath); this.storagePath = Path.of(storagePath);
} }
@Transactional
public Document upload(MultipartFile file) { public Document upload(MultipartFile file) {
if (file.isEmpty() || file.getOriginalFilename() == null) { if (file.isEmpty() || file.getOriginalFilename() == null) {
throw new IllegalArgumentException("File is empty or has no name"); throw new IllegalArgumentException("File is empty or has no name");
@ -46,6 +54,7 @@ public class DocumentService {
file.getSize(), file.getSize(),
target.toString() target.toString()
); );
doc.setPageCount(countPages(target));
return repository.save(doc); return repository.save(doc);
} catch (IOException e) { } catch (IOException e) {
@ -66,6 +75,7 @@ public class DocumentService {
.orElseThrow(() -> new ResourceNotFoundException("Document not found: " + id)); .orElseThrow(() -> new ResourceNotFoundException("Document not found: " + id));
} }
@Transactional
public void delete(UUID id) { public void delete(UUID id) {
Document doc = findById(id); Document doc = findById(id);
try { try {
@ -85,4 +95,13 @@ public class DocumentService {
Document doc = findById(id); Document doc = findById(id);
return Path.of(doc.getStoragePath()); 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;
}
}
} }

View file

@ -3,9 +3,9 @@ server:
spring: spring:
datasource: datasource:
url: jdbc:postgresql://localhost:5432/docling_studio url: jdbc:postgresql://${DB_HOST:localhost}:5432/${DB_NAME:docling_studio}
username: app username: ${SPRING_DATASOURCE_USERNAME:app}
password: app password: ${SPRING_DATASOURCE_PASSWORD:app}
jpa: jpa:
hibernate: hibernate:
ddl-auto: validate ddl-auto: validate
@ -19,6 +19,8 @@ spring:
app: app:
document-parser: document-parser:
base-url: http://localhost:8000 base-url: ${APP_DOCUMENT-PARSER_BASE-URL:http://localhost:8000}
storage: storage:
path: ./uploads path: ${APP_STORAGE_PATH:./uploads}
cors:
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173}

View file

@ -2,15 +2,15 @@ services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_USER: app POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: app POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
POSTGRES_DB: docling_studio POSTGRES_DB: ${POSTGRES_DB:-docling_studio}
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: 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 interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10

View file

@ -2,15 +2,15 @@ services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_USER: app POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: app POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
POSTGRES_DB: docling_studio POSTGRES_DB: ${POSTGRES_DB:-docling_studio}
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: 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 interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@ -20,6 +20,10 @@ services:
context: ./document-parser context: ./document-parser
ports: ports:
- "8000:8000" - "8000:8000"
deploy:
resources:
limits:
memory: 4g
backend: backend:
build: build:
@ -27,10 +31,12 @@ services:
ports: ports:
- "8081:8081" - "8081:8081"
environment: environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/docling_studio SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-docling_studio}
SPRING_DATASOURCE_USERNAME: app SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-app}
SPRING_DATASOURCE_PASSWORD: app SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-app}
APP_DOCUMENT-PARSER_BASE-URL: http://document-parser:8000 APP_DOCUMENT-PARSER_BASE-URL: http://document-parser:8000
volumes:
- uploads_data:/app/uploads
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -45,3 +51,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
uploads_data:

View file

@ -0,0 +1,12 @@
.venv/
__pycache__/
*.pyc
*.pyo
*.egg-info/
.git/
.gitignore
.env
*.log
.mypy_cache/
.pytest_cache/
.ruff_cache/

View file

@ -2,6 +2,8 @@ FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
poppler-utils \ poppler-utils \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app

View file

@ -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 io
import logging import logging
import os import os
import tempfile import tempfile
import threading
from pathlib import Path from pathlib import Path
from typing import Optional
from fastapi import FastAPI, UploadFile, HTTPException, Query from fastapi import FastAPI, UploadFile, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from docling.document_converter import DocumentConverter
from pdf2image import convert_from_bytes 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 from bbox import to_topleft_list
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
app = FastAPI(title="Docling Studio - Document Parser") app = FastAPI(title="Docling Studio — Document Parser")
converter = DocumentConverter()
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB 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): class PageElement(BaseModel):
type: str type: str
bbox: list[float] bbox: list[float]
content: str content: str
level: int = 0 # Hierarchy depth from iterate_items()
class PageDetail(BaseModel): class PageDetail(BaseModel):
@ -43,56 +124,94 @@ class ParseResponse(BaseModel):
content_markdown: str content_markdown: str
content_html: str content_html: str
pages: list[PageDetail] 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. """Extract per-page element details with bounding boxes from Docling result.
Uses Docling's iterate_items() API (preferred) or falls back to document.texts. Returns (pages, skipped_count) for transparent error reporting.
Both provide a flat iteration over all content items, avoiding duplicates.
""" """
pages: dict[int, PageDetail] = {} pages: dict[int, PageDetail] = {}
document = doc_result.document document = doc_result.document
skipped = 0
# Get page dimensions from document pages # Populate page dimensions from document metadata
if hasattr(document, 'pages') and document.pages: if hasattr(document, "pages") and document.pages:
for page_key, page_obj in document.pages.items(): for page_key, page_obj in document.pages.items():
page_no = int(page_key) if isinstance(page_key, str) else page_key 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 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 height = page_obj.size.height if hasattr(page_obj, "size") and page_obj.size else 792.0
pages[page_no] = PageDetail( pages[page_no] = PageDetail(
page_number=page_no, page_number=page_no, width=width, height=height, elements=[]
width=width,
height=height,
elements=[]
) )
# Use iterate_items() (Docling v2 API) — avoids duplicates # Use iterate_items() — the Docling v2 API that yields (item, level)
if hasattr(document, 'iterate_items'): if hasattr(document, "iterate_items"):
for item, _level in document.iterate_items(): for item, level in document.iterate_items():
_process_content_item(item, pages) ok = _process_content_item(item, level, pages)
elif hasattr(document, 'texts'): if not ok:
skipped += 1
elif hasattr(document, "texts"):
for text_item in 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 sorted_pages = sorted(pages.values(), key=lambda p: p.page_number)
return 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. """Process a single content item and add it to the appropriate page.
Silently skips items that lack provenance or fail to process, Returns True on success, False if the item was skipped.
so one bad item doesn't break the whole extraction.
""" """
if not hasattr(item, 'prov') or not item.prov: # Skip groups and items without provenance
return 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: for prov in item.prov:
try: 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: if page_no not in pages:
pages[page_no] = PageDetail( pages[page_no] = PageDetail(
@ -101,108 +220,129 @@ def _process_content_item(item, pages: dict[int, PageDetail]):
page_height = pages[page_no].height page_height = pages[page_no].height
bbox = [0, 0, 0, 0] bbox = [0.0, 0.0, 0.0, 0.0]
if hasattr(prov, 'bbox') and prov.bbox: if hasattr(prov, "bbox") and prov.bbox:
b = prov.bbox b = prov.bbox
if hasattr(b, 'l'): if hasattr(b, "l"):
bbox = to_topleft_list(b, page_height) bbox = to_topleft_list(b, page_height)
elif isinstance(b, (list, tuple)) and len(b) >= 4: elif isinstance(b, (list, tuple)) and len(b) >= 4:
bbox = list(b[:4]) bbox = list(b[:4])
element_type = _get_element_type(item) element_type = _get_element_type(item)
content = ""
if hasattr(item, 'text'):
content = item.text or ""
pages[page_no].elements.append(PageElement( content = ""
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, type=element_type,
bbox=bbox, bbox=bbox,
content=content[:500] content=content,
)) level=level,
)
)
except Exception: 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: # Endpoints
"""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 ---
@app.post("/parse", response_model=ParseResponse) @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: if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided") raise HTTPException(status_code=400, detail="No filename provided")
content = await file.read() file_content = await file.read()
if len(content) > MAX_FILE_SIZE: if len(file_content) > MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large (max 50 MB)") raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
suffix = Path(file.filename).suffix suffix = Path(file.filename).suffix
tmp_path = None tmp_path = None
try: try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(content) tmp.write(file_content)
tmp_path = tmp.name 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 doc = result.document
content_markdown = doc.export_to_markdown() 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 page_count = len(doc.pages) if hasattr(doc, "pages") and doc.pages else 0
if hasattr(doc, 'pages') and doc.pages:
page_count = len(doc.pages)
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: if not pages_detail and page_count > 0:
pages_detail = [ pages_detail = [
PageDetail(page_number=i + 1, width=612.0, height=792.0, elements=[]) PageDetail(page_number=i + 1, width=612.0, height=792.0, elements=[])
for i in range(page_count) 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( return ParseResponse(
filename=file.filename, filename=file.filename,
page_count=page_count or len(pages_detail) or 1, page_count=page_count or len(pages_detail) or 1,
content_markdown=content_markdown, content_markdown=content_markdown,
content_html=content_html, content_html=content_html,
pages=pages_detail, pages=pages_detail,
skipped_items=skipped,
) )
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.exception("Failed to parse document: %s", file.filename) 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: finally:
if tmp_path and os.path.exists(tmp_path): if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path) os.unlink(tmp_path)
except OSError:
logger.warning("Could not delete temp file: %s", tmp_path)
@app.post("/preview") @app.post("/preview")
@ -211,16 +351,16 @@ async def preview(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
dpi: int = Query(150, ge=72, le=300), 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: if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided") raise HTTPException(status_code=400, detail="No filename provided")
content = await file.read() file_content = await file.read()
if len(content) > MAX_FILE_SIZE: if len(file_content) > MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large (max 50 MB)") raise HTTPException(status_code=413, detail="File too large (max 50 MB)")
try: 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: if not images:
raise HTTPException(status_code=404, detail=f"Page {page} not found") raise HTTPException(status_code=404, detail=f"Page {page} not found")
@ -229,10 +369,14 @@ async def preview(
buf.seek(0) buf.seek(0)
return StreamingResponse(buf, media_type="image/png") return StreamingResponse(buf, media_type="image/png")
except HTTPException:
raise
except Exception as e: 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") @app.get("/health")
def health(): def health():
"""Health check endpoint."""
return {"status": "ok"} return {"status": "ok"}

View file

@ -1,6 +1,7 @@
docling docling>=2.80.0,<3.0.0
fastapi docling-core>=2.0.0,<3.0.0
uvicorn[standard] fastapi>=0.115.0,<1.0.0
python-multipart uvicorn[standard]>=0.32.0,<1.0.0
pdf2image python-multipart>=0.0.12
pillow pdf2image>=1.17.0,<2.0.0
pillow>=10.0.0,<11.0.0

View file

@ -40,12 +40,14 @@ import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount }
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling.js' import { computeScale, bboxToRect, pointInRect } from '../bboxScaling.js'
const ELEMENT_COLORS = { const ELEMENT_COLORS = {
title: '#EF4444',
section_header: '#F97316', section_header: '#F97316',
text: '#3B82F6', text: '#3B82F6',
table: '#8B5CF6', table: '#8B5CF6',
picture: '#22C55E', picture: '#22C55E',
list: '#06B6D4', list: '#06B6D4',
formula: '#EC4899', formula: '#EC4899',
code: '#14B8A6',
caption: '#EAB308' caption: '#EAB308'
} }

View file

@ -83,17 +83,27 @@ const pageMarkdown = computed(() => {
function formatElement(el) { function formatElement(el) {
if (!el.content) return '' if (!el.content) return ''
const indent = ' '.repeat(Math.max(0, (el.level || 0) - 1))
switch (el.type) { switch (el.type) {
case 'section_header': case 'title':
return `## ${el.content}` 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': case 'caption':
return `*${el.content}*` return `${indent}*${el.content}*`
case 'table': case 'table':
return el.content return el.content
case 'formula': case 'formula':
return `$$${el.content}$$` return `${indent}$$${el.content}$$`
case 'code':
return `${indent}\`\`\`\n${el.content}\n\`\`\``
case 'list':
return `${indent}- ${el.content}`
default: default:
return el.content return `${indent}${el.content}`
} }
} }
</script> </script>