Work on full Docker integration
This commit is contained in:
parent
e76012f341
commit
4c6aff3029
20 changed files with 653 additions and 187 deletions
17
.env.example
Normal file
17
.env.example
Normal 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
12
.gitignore
vendored
|
|
@ -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
21
LICENSE
Normal 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
152
README.md
|
|
@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
</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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
return webClient.post()
|
try {
|
||||||
.uri("/parse")
|
return webClient.post()
|
||||||
.body(BodyInserters.fromMultipartData(builder.build()))
|
.uri("/parse")
|
||||||
.retrieve()
|
.body(BodyInserters.fromMultipartData(builder.build()))
|
||||||
.bodyToMono(Map.class)
|
.retrieve()
|
||||||
.block();
|
.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) {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
12
document-parser/.dockerignore
Normal file
12
document-parser/.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
content = ""
|
||||||
if hasattr(item, 'text'):
|
if hasattr(item, "text"):
|
||||||
content = item.text or ""
|
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(
|
pages[page_no].elements.append(
|
||||||
type=element_type,
|
PageElement(
|
||||||
bbox=bbox,
|
type=element_type,
|
||||||
content=content[:500]
|
bbox=bbox,
|
||||||
))
|
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 50MB)")
|
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):
|
||||||
os.unlink(tmp_path)
|
try:
|
||||||
|
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 50MB)")
|
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"}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue