Merge pull request #5 from scub-france/complete-refacto
Make a new project base, managing new Docling version
This commit is contained in:
commit
21789659b0
91 changed files with 4909 additions and 6043 deletions
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "frontend-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000,
|
||||
"cwd": "frontend"
|
||||
}
|
||||
]
|
||||
}
|
||||
44
.github/workflows/release.yml
vendored
44
.github/workflows/release.yml
vendored
|
|
@ -1,44 +0,0 @@
|
|||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- docker
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set image name
|
||||
id: image_name
|
||||
run: |
|
||||
IMAGE_NAME=ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -t ${{ steps.image_name.outputs.IMAGE_NAME }} .
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push ${{ steps.image_name.outputs.IMAGE_NAME }}
|
||||
- name: Tag image as latest
|
||||
run: |
|
||||
docker tag ${{ steps.image_name.outputs.IMAGE_NAME }} ghcr.io/${{ github.repository }}:latest
|
||||
docker push ghcr.io/${{ github.repository }}:latest
|
||||
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -1,7 +1,28 @@
|
|||
.idea
|
||||
document_output
|
||||
results
|
||||
docs
|
||||
# Dependencies
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
|
||||
# Build outputs
|
||||
frontend/dist/
|
||||
backend/target/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pdf
|
||||
*.pyc
|
||||
.venv/
|
||||
|
|
|
|||
16
.run/DoclingStudio Backend.run.xml
Normal file
16
.run/DoclingStudio Backend.run.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="DoclingStudio Backend" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
|
||||
<module name="docling-studio" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.docling.studio.DoclingStudioApplication" />
|
||||
<option name="ACTIVE_PROFILES" />
|
||||
<envs>
|
||||
<env name="SPRING_DATASOURCE_URL" value="jdbc:postgresql://localhost:5432/docling_studio" />
|
||||
<env name="SPRING_DATASOURCE_USERNAME" value="app" />
|
||||
<env name="SPRING_DATASOURCE_PASSWORD" value="app" />
|
||||
<env name="APP_DOCUMENT-PARSER_BASE-URL" value="http://localhost:8000" />
|
||||
</envs>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
FROM python:3.12
|
||||
# Install poppler for PDF processing
|
||||
RUN apt-get update && apt-get install -y poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY backend/requirements.txt ./backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
COPY . .
|
||||
RUN chmod +x run_app.sh
|
||||
ENTRYPOINT ["./run_app.sh"]
|
||||
130
README.md
130
README.md
|
|
@ -1,111 +1,51 @@
|
|||
# DocTags Analyzer and Visualizer
|
||||
# Docling Studio
|
||||
|
||||
AI-powered document analysis and visualization tool for extracting structured content from PDFs.
|
||||
A professional document analysis studio powered by [Docling](https://github.com/DS4SD/docling), inspired by MistralAI Studio.
|
||||
|
||||
<img width="800" height="685" alt="image" src="https://github.com/user-attachments/assets/ce301175-ea4b-42e8-8e3a-ff9c326b84cd" />
|
||||
## Architecture
|
||||
|
||||
```
|
||||
frontend/ → Vue 3 + Vite + Pinia (port 3000)
|
||||
backend/ → Spring Boot 3.3.5 / Java 21 (port 8081)
|
||||
document-parser/ → FastAPI + Docling (port 8000)
|
||||
```
|
||||
|
||||
## 🚀 Quick Start with Docker
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose installed
|
||||
- At least 4GB of free memory
|
||||
- ~500MB disk space for the AI model
|
||||
|
||||
### Running with Docker
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd SmolDocling-visualizer
|
||||
```
|
||||
|
||||
2. **Place your PDF files in the project directory**
|
||||
```bash
|
||||
cp /path/to/your/document.pdf ./
|
||||
```
|
||||
|
||||
3. **Start the application**
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
4. **Access the web interface**
|
||||
- Open http://localhost:8080 in your browser
|
||||
- Select a PDF from the dropdown
|
||||
- Process your documents through the three-step workflow
|
||||
|
||||
### First Run Notice
|
||||
⚠️ **Important**: The first analysis will take 5-10 minutes as the AI model (SmolDocling-256M) needs to be downloaded (~500MB). Subsequent runs will be much faster (30-60 seconds).
|
||||
|
||||
## 📋 Features
|
||||
|
||||
- **Document Analysis**: Extract comprehensive document structure using AI
|
||||
- **Visualization**: Generate visual overlays showing document elements
|
||||
- **Image Extraction**: Automatically extract and catalog embedded images
|
||||
- **Web Interface**: User-friendly interface for document processing
|
||||
|
||||
## 🛠️ Manual Usage
|
||||
|
||||
Process PDF pages with DocTags:
|
||||
### Docker Compose (recommended)
|
||||
|
||||
```bash
|
||||
python analyzer.py --image document.pdf --page 8 && python visualizer.py --doctags results/output.doctags.txt --pdf document.pdf --page 8 --adjust && python picture_extractor.py --doctags results/output.doctags.txt --pdf document.pdf --page 8 --adjust
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
Open [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
### Docker Issues
|
||||
### Local Development
|
||||
|
||||
1. **Container won't start**
|
||||
- Check logs: `docker-compose logs analyser`
|
||||
- Ensure ports aren't in use: `lsof -i :8080`
|
||||
|
||||
2. **"No module named 'docling_core'" error**
|
||||
- Rebuild the container: `docker-compose down && docker-compose up -d --build`
|
||||
|
||||
3. **Analysis stuck on "Running..."**
|
||||
- First run downloads the AI model (~500MB), this can take 5-10 minutes
|
||||
- Check progress: `docker-compose exec analyser du -sh /root/.cache/huggingface/`
|
||||
- Monitor CPU usage: `docker-compose exec analyser ps aux | grep analyzer`
|
||||
|
||||
4. **PDF not loading**
|
||||
- Ensure poppler is installed (already included in Dockerfile)
|
||||
- Place PDFs in the project root directory
|
||||
- PDFs must have `.pdf` extension
|
||||
|
||||
### Performance Tips
|
||||
|
||||
- First analysis is slow due to model download
|
||||
- Subsequent analyses are much faster (model is cached)
|
||||
- Processing time depends on PDF complexity and page size
|
||||
- Monitor memory usage: `docker-compose exec analyser free -h`
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
doc-analyzer/
|
||||
├── backend/
|
||||
│ ├── page_treatment/ # Core processing scripts
|
||||
│ │ ├── analyzer.py # AI-powered document analysis
|
||||
│ │ ├── visualizer.py # Visualization generator
|
||||
│ │ └── picture_extractor.py # Image extraction
|
||||
│ ├── app.py # Flask web application
|
||||
│ └── requirements.txt # Python dependencies
|
||||
├── frontend/ # Web interface
|
||||
├── results/ # Output directory (auto-created)
|
||||
├── Dockerfile # Docker configuration
|
||||
└── docker-compose.yml # Docker Compose setup
|
||||
**Document Parser:**
|
||||
```bash
|
||||
cd document-parser
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
## 🔧 Development
|
||||
**Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
./mvnw spring-boot:run
|
||||
```
|
||||
|
||||
To modify the application:
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
1. Make changes to the code
|
||||
2. Rebuild the Docker image: `docker-compose up -d --build`
|
||||
3. Check logs for errors: `docker-compose logs -f analyser`
|
||||
## Features
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is open source and available under the MIT License.
|
||||
- PDF upload and document analysis via Docling
|
||||
- Extracted content viewing (Markdown, HTML)
|
||||
- Document structure visualization with bounding boxes
|
||||
- Image detection
|
||||
- Analysis history
|
||||
|
|
|
|||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM eclipse-temurin:21-jdk AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY pom.xml .
|
||||
COPY src ./src
|
||||
|
||||
RUN apt-get update && apt-get install -y maven && \
|
||||
mvn package -DskipTests
|
||||
|
||||
FROM eclipse-temurin:21-jre
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/target/*.jar app.jar
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
844
backend/app.py
844
backend/app.py
|
|
@ -1,844 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Flask application for DocTags web interface
|
||||
"""
|
||||
|
||||
from flask import Flask, request, send_file, jsonify
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from backend.utils import (ensure_results_folder, count_pdf_pages,
|
||||
run_command_with_timeout, format_duration)
|
||||
from backend.config import (HOST, PORT, MAX_CONTENT_LENGTH, ALLOWED_EXTENSIONS,
|
||||
PREVIEW_DPI, PROCESSING_TIMEOUT, CLEANUP_INTERVAL,
|
||||
CLEANUP_AGE_HOURS)
|
||||
from backend.multipart_handler import default_handler
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize Flask app
|
||||
frontend_path = Path(__file__).parent.parent / 'frontend'
|
||||
app = Flask(__name__,
|
||||
static_folder=frontend_path / 'static',
|
||||
static_url_path='/static')
|
||||
|
||||
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
|
||||
|
||||
# Task results storage
|
||||
task_results = {}
|
||||
|
||||
# Import batch processor if available
|
||||
try:
|
||||
from backend.batch_treatment.batch_processor import (
|
||||
start_batch_processing, get_batch_processor, cleanup_old_batches
|
||||
)
|
||||
batch_processing_available = True
|
||||
except ImportError:
|
||||
logger.warning("Batch processing not available")
|
||||
batch_processing_available = False
|
||||
|
||||
# Ensure required directories exist
|
||||
ensure_results_folder()
|
||||
|
||||
# Routes
|
||||
@app.route('/')
|
||||
def index():
|
||||
return send_file(frontend_path / 'index.html')
|
||||
|
||||
@app.route('/batch')
|
||||
def batch_interface():
|
||||
return send_file(frontend_path / 'batch.html')
|
||||
|
||||
@app.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
return send_file(frontend_path / 'static' / filename)
|
||||
|
||||
@app.route('/pdf-files')
|
||||
def pdf_files():
|
||||
try:
|
||||
pdf_files = [f for f in os.listdir('.') if f.endswith('.pdf')]
|
||||
logger.info(f"Found {len(pdf_files)} PDF files")
|
||||
return jsonify(pdf_files)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing PDF files: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/pdf-info/<path:pdf_file>')
|
||||
def pdf_info(pdf_file):
|
||||
"""Get information about a PDF file"""
|
||||
try:
|
||||
if not os.path.exists(pdf_file):
|
||||
return jsonify({'error': f'PDF file not found: {pdf_file}'}), 404
|
||||
|
||||
page_count = count_pdf_pages(pdf_file)
|
||||
return jsonify({
|
||||
'pageCount': page_count,
|
||||
'filename': os.path.basename(pdf_file),
|
||||
'size': os.path.getsize(pdf_file)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting PDF info: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/pdf-preview/<pdf_file>/<int:page_num>')
|
||||
def pdf_preview(pdf_file, page_num):
|
||||
"""Generate and serve a preview image of a PDF page"""
|
||||
try:
|
||||
import pdf2image
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
if not os.path.exists(pdf_file):
|
||||
return jsonify({'error': f'PDF file not found: {pdf_file}'}), 404
|
||||
|
||||
logger.info(f"Generating preview for {pdf_file} page {page_num}")
|
||||
|
||||
# Convert PDF page to image
|
||||
pdf_images = pdf2image.convert_from_path(
|
||||
pdf_file, dpi=PREVIEW_DPI,
|
||||
first_page=page_num, last_page=page_num
|
||||
)
|
||||
|
||||
if not pdf_images:
|
||||
return jsonify({'error': f'Could not extract page {page_num}'}), 400
|
||||
|
||||
pil_image = pdf_images[0]
|
||||
|
||||
# Resize if too large
|
||||
max_width = 1200
|
||||
if pil_image.width > max_width:
|
||||
ratio = max_width / pil_image.width
|
||||
new_height = int(pil_image.height * ratio)
|
||||
pil_image = pil_image.resize((max_width, new_height), Image.LANCZOS)
|
||||
|
||||
# Convert to bytes
|
||||
img_io = io.BytesIO()
|
||||
pil_image.save(img_io, 'PNG', optimize=True)
|
||||
img_io.seek(0)
|
||||
|
||||
return send_file(img_io, mimetype='image/png')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating PDF preview: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
def run_command(task_id, command):
|
||||
"""Run a command in a background thread and store result"""
|
||||
logger.info(f"Running command: {command}")
|
||||
try:
|
||||
# Run the command with pipe input to automatically answer "n" to prompts
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
# Send "n" to the process to bypass prompts
|
||||
stdout, stderr = process.communicate(input="n\n")
|
||||
|
||||
# Log output for debugging
|
||||
logger.info(f"Command stdout: {stdout[:500]}...")
|
||||
if stderr:
|
||||
logger.error(f"Command stderr: {stderr}")
|
||||
|
||||
# Update task result
|
||||
if process.returncode == 0:
|
||||
task_results[task_id] = {
|
||||
'success': True,
|
||||
'output': stdout,
|
||||
'done': True
|
||||
}
|
||||
logger.info(f"Command completed successfully: {task_id}")
|
||||
else:
|
||||
error_message = stderr or f"Command failed with return code {process.returncode}"
|
||||
task_results[task_id] = {
|
||||
'success': False,
|
||||
'error': error_message,
|
||||
'done': True
|
||||
}
|
||||
logger.error(f"Command failed: {task_id} - {error_message}")
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Unexpected error: {task_id} - {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
task_results[task_id] = {
|
||||
'success': False,
|
||||
'error': f"Exception: {str(e)}",
|
||||
'done': True
|
||||
}
|
||||
|
||||
@app.route('/run-analyzer', methods=['POST'])
|
||||
def run_analyzer():
|
||||
try:
|
||||
pdf_file = request.form.get('pdf_file')
|
||||
page_num = request.form.get('page_num', 1)
|
||||
|
||||
if not pdf_file:
|
||||
return jsonify({'success': False, 'error': 'PDF file not specified'}), 400
|
||||
|
||||
if not os.path.exists(pdf_file):
|
||||
return jsonify({'success': False, 'error': f'PDF file not found: {pdf_file}'}), 404
|
||||
|
||||
command = f"python backend/page_treatment/analyzer.py --image {pdf_file} --page {page_num} --start-page {page_num} --end-page {page_num}"
|
||||
|
||||
# Generate task ID
|
||||
task_id = f"analyzer_{int(time.time())}"
|
||||
|
||||
# Initialize task result
|
||||
task_results[task_id] = {
|
||||
'success': None,
|
||||
'output': "Running analyzer...",
|
||||
'done': False
|
||||
}
|
||||
|
||||
# Start background thread
|
||||
thread = threading.Thread(target=run_command, args=(task_id, command))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'task_id': task_id,
|
||||
'message': f"Analyzer started. Processing {pdf_file} page {page_num}..."
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting analyzer: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/run-visualizer', methods=['POST'])
|
||||
def run_visualizer():
|
||||
try:
|
||||
pdf_file = request.form.get('pdf_file')
|
||||
page_num = request.form.get('page_num', 1)
|
||||
adjust = request.form.get('adjust') == 'true'
|
||||
|
||||
if not pdf_file:
|
||||
return jsonify({'success': False, 'error': 'PDF file not specified'}), 400
|
||||
|
||||
command = f"python backend/page_treatment/visualizer.py --doctags results/output.doctags.txt --pdf {pdf_file} --page {page_num}"
|
||||
if adjust:
|
||||
command += " --adjust"
|
||||
|
||||
# Generate task ID with page number
|
||||
task_id = f"visualizer_{int(time.time())}_{page_num}"
|
||||
|
||||
# Initialize task result
|
||||
task_results[task_id] = {
|
||||
'success': None,
|
||||
'output': "Running visualizer...",
|
||||
'done': False
|
||||
}
|
||||
|
||||
# Start background thread
|
||||
thread = threading.Thread(target=run_command, args=(task_id, command))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'task_id': task_id,
|
||||
'message': f"Visualizer started. Processing {pdf_file} page {page_num}..."
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting visualizer: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/run-extractor', methods=['POST'])
|
||||
def run_extractor():
|
||||
try:
|
||||
pdf_file = request.form.get('pdf_file')
|
||||
page_num = request.form.get('page_num', 1)
|
||||
adjust = request.form.get('adjust') == 'true'
|
||||
|
||||
if not pdf_file:
|
||||
return jsonify({'success': False, 'error': 'PDF file not specified'}), 400
|
||||
|
||||
command = f"python backend/page_treatment/picture_extractor.py --doctags results/output.doctags.txt --pdf {pdf_file} --page {page_num}"
|
||||
if adjust:
|
||||
command += " --adjust"
|
||||
|
||||
# Generate task ID with page number
|
||||
task_id = f"extractor_{int(time.time())}_{page_num}"
|
||||
|
||||
# Initialize task result
|
||||
task_results[task_id] = {
|
||||
'success': None,
|
||||
'output': "Running picture extractor...",
|
||||
'done': False
|
||||
}
|
||||
|
||||
# Start background thread
|
||||
thread = threading.Thread(target=run_command, args=(task_id, command))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'task_id': task_id,
|
||||
'message': f"Picture extractor started. Processing {pdf_file} page {page_num}..."
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting extractor: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def run_processing_task(task_type, form_data):
|
||||
"""Generic function to run processing tasks"""
|
||||
try:
|
||||
pdf_file = form_data.get('pdf_file')
|
||||
page_num = form_data.get('page_num', '1')
|
||||
|
||||
if not pdf_file:
|
||||
return jsonify({'success': False, 'error': 'PDF file not specified'}), 400
|
||||
|
||||
if not os.path.exists(pdf_file):
|
||||
return jsonify({'success': False, 'error': f'PDF file not found: {pdf_file}'}), 404
|
||||
|
||||
# Build command based on task type
|
||||
command = build_command(task_type, pdf_file, page_num, form_data)
|
||||
|
||||
# Generate task ID with page number
|
||||
task_id = f"{task_type}_{int(time.time() * 1000)}_{page_num}"
|
||||
|
||||
# Initialize task result
|
||||
with task_lock:
|
||||
task_results[task_id] = {
|
||||
'success': None,
|
||||
'output': f"Running {task_type}...",
|
||||
'done': False
|
||||
}
|
||||
|
||||
logger.info(f"Created task {task_id} for {task_type} on page {page_num}")
|
||||
|
||||
# Start background thread
|
||||
thread = threading.Thread(target=run_background_task, args=(task_id, command))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'task_id': task_id,
|
||||
'message': f"{task_type.capitalize()} started for {pdf_file} page {page_num}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting {task_type}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def build_command(task_type, pdf_file, page_num, form_data):
|
||||
"""Build command for different task types"""
|
||||
adjust = form_data.get('adjust') == 'true'
|
||||
|
||||
if task_type == 'analyzer':
|
||||
return (f"python backend/page_treatment/analyzer.py --image {pdf_file} "
|
||||
f"--page {page_num} --start-page {page_num} --end-page {page_num}")
|
||||
|
||||
elif task_type == 'visualizer':
|
||||
cmd = (f"python backend/page_treatment/visualizer.py "
|
||||
f"--doctags results/output.doctags.txt --pdf {pdf_file} --page {page_num}")
|
||||
if adjust:
|
||||
cmd += " --adjust"
|
||||
return cmd
|
||||
|
||||
elif task_type == 'extractor':
|
||||
cmd = (f"python backend/page_treatment/picture_extractor.py "
|
||||
f"--doctags results/output.doctags.txt --pdf {pdf_file} --page {page_num}")
|
||||
if adjust:
|
||||
cmd += " --adjust"
|
||||
return cmd
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown task type: {task_type}")
|
||||
|
||||
@app.route('/task-status/<task_id>')
|
||||
def task_status(task_id):
|
||||
if task_id not in task_results:
|
||||
logger.warning(f"Task {task_id} not found in task_results")
|
||||
return jsonify({'success': False, 'error': 'Task not found'}), 404
|
||||
|
||||
result = task_results[task_id].copy()
|
||||
|
||||
# Log the complete result for debugging
|
||||
logger.info(f"Task {task_id} result: {result}")
|
||||
|
||||
# If task is completed successfully, add file paths
|
||||
if result.get('done') and result.get('success'):
|
||||
if task_id.startswith('analyzer_'):
|
||||
doctags_path = Path("results") / "output.doctags.txt"
|
||||
if doctags_path.exists():
|
||||
result['doctags_file'] = "results/output.doctags.txt"
|
||||
logger.info(f"Added doctags_file to result")
|
||||
else:
|
||||
logger.warning(f"DocTags file not found: {doctags_path}")
|
||||
|
||||
elif task_id.startswith('visualizer_'):
|
||||
# Extract page number from the end of task_id if it exists
|
||||
page_num = task_id.split('_')[-1] if len(task_id.split('_')) > 2 else "1"
|
||||
viz_filename = f"visualization_page_{page_num}.png"
|
||||
result['image_file'] = f"results/{viz_filename}"
|
||||
logger.info(f"Found visualization at: {result['image_file']}")
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/results/<path:filename>')
|
||||
def serve_results(filename):
|
||||
"""Serve files from results directory"""
|
||||
try:
|
||||
results_dir = ensure_results_folder()
|
||||
file_path = results_dir / filename
|
||||
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return send_file(file_path)
|
||||
else:
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return jsonify({'error': f"File not found: {filename}"}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving file {filename}: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/check-environment')
|
||||
def check_environment():
|
||||
"""Check system environment and configuration"""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
# Check for required scripts
|
||||
required_scripts = ['analyzer.py', 'visualizer.py', 'picture_extractor.py']
|
||||
backend_dir = Path('backend/page_treatment')
|
||||
missing_scripts = [s for s in required_scripts if not (backend_dir / s).exists()]
|
||||
|
||||
# Get Python version
|
||||
result = subprocess.run(['python', '--version'], capture_output=True, text=True)
|
||||
python_version = result.stdout.strip() if result.returncode == 0 else "Unknown"
|
||||
|
||||
# Check results directory
|
||||
results_dir = ensure_results_folder()
|
||||
|
||||
return jsonify({
|
||||
'cwd': os.getcwd(),
|
||||
'files': os.listdir('.')[:50], # Limit to 50 files
|
||||
'missing_scripts': missing_scripts,
|
||||
'pdf_files': [f for f in os.listdir('.') if f.endswith('.pdf')],
|
||||
'results_dir_exists': results_dir.exists(),
|
||||
'results_dir_writable': os.access(results_dir, os.W_OK),
|
||||
'python_version': python_version,
|
||||
'batch_processing_available': batch_processing_available
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking environment: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/run-manual-command', methods=['POST'])
|
||||
def run_manual_command():
|
||||
"""Run a manual command for debugging"""
|
||||
try:
|
||||
command = request.form.get('command')
|
||||
if not command:
|
||||
return jsonify({'success': False, 'error': 'No command specified'}), 400
|
||||
|
||||
logger.info(f"Running manual command: {command}")
|
||||
|
||||
# Update paths in command if needed
|
||||
if 'backend/page_treatment/' not in command:
|
||||
for script in ['analyzer.py', 'visualizer.py', 'picture_extractor.py']:
|
||||
command = command.replace(script, f'backend/page_treatment/{script}')
|
||||
|
||||
success, stdout, stderr = run_command_with_timeout(command, 60)
|
||||
|
||||
return jsonify({
|
||||
'success': success,
|
||||
'output': stdout,
|
||||
'error': stderr
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running manual command: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# Batch processing endpoints
|
||||
if batch_processing_available:
|
||||
@app.route('/run-batch-processor', methods=['POST'])
|
||||
def run_batch_processor():
|
||||
"""Start batch processing job"""
|
||||
try:
|
||||
pdf_file = request.form.get('pdf_file')
|
||||
start_page = int(request.form.get('start_page', 1))
|
||||
end_page = int(request.form.get('end_page', 1))
|
||||
|
||||
if not pdf_file or not os.path.exists(pdf_file):
|
||||
return jsonify({'success': False, 'error': 'Invalid PDF file'}), 400
|
||||
|
||||
# Check if there's already an active batch for this PDF
|
||||
from backend.batch_treatment.batch_processor import batch_processors, batch_lock
|
||||
with batch_lock:
|
||||
for batch_id, processor in batch_processors.items():
|
||||
if (processor.pdf_file == pdf_file and
|
||||
not processor.state['completed'] and
|
||||
not processor.state['cancelled']):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'A batch process is already running for this PDF',
|
||||
'existing_batch_id': batch_id
|
||||
}), 409
|
||||
|
||||
options = {
|
||||
'adjust': request.form.get('adjust') == 'true',
|
||||
'parallel': request.form.get('parallel') == 'true',
|
||||
'generate_report': request.form.get('generate_report') == 'true'
|
||||
}
|
||||
|
||||
batch_id = str(uuid.uuid4())[:8]
|
||||
|
||||
if start_batch_processing(batch_id, pdf_file, start_page, end_page, options):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'batch_id': batch_id,
|
||||
'message': f'Started processing {end_page - start_page + 1} pages'
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Failed to start'}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting batch: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/batch-status/<batch_id>')
|
||||
def batch_status(batch_id):
|
||||
"""Get batch processing status"""
|
||||
processor = get_batch_processor(batch_id)
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
state = processor.get_state()
|
||||
state['logs'] = state['logs'][-20:] # Last 20 logs only
|
||||
return jsonify(state)
|
||||
|
||||
|
||||
# Add these routes to your app.py file after the other batch processing endpoints
|
||||
|
||||
@app.route('/batch-report/<batch_id>')
|
||||
def batch_report(batch_id):
|
||||
"""Serve the batch processing report HTML"""
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
if not processor:
|
||||
# Try to find existing report even if processor is gone
|
||||
report_path = ensure_results_folder() / f"batch_{batch_id}" / "report.html"
|
||||
if report_path.exists():
|
||||
return send_file(report_path)
|
||||
else:
|
||||
return jsonify({'error': 'Batch report not found'}), 404
|
||||
|
||||
# Check if report exists
|
||||
report_path = processor.results_dir / "report.html"
|
||||
if not report_path.exists():
|
||||
# Generate report if it doesn't exist
|
||||
processor.generate_report()
|
||||
|
||||
return send_file(report_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving batch report: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/batch-report-image/<batch_id>/<image_name>')
|
||||
def batch_report_image(batch_id, image_name):
|
||||
"""Serve images from batch report directory"""
|
||||
try:
|
||||
image_path = ensure_results_folder() / f"batch_{batch_id}" / image_name
|
||||
|
||||
if not image_path.exists():
|
||||
return jsonify({'error': 'Image not found'}), 404
|
||||
|
||||
return send_file(image_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving batch image: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/pause-batch/<batch_id>', methods=['POST'])
|
||||
def pause_batch(batch_id):
|
||||
"""Pause a batch processing job"""
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
processor.pause()
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error pausing batch: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/resume-batch/<batch_id>', methods=['POST'])
|
||||
def resume_batch(batch_id):
|
||||
"""Resume a batch processing job"""
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
processor.resume()
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error resuming batch: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/cancel-batch/<batch_id>', methods=['POST'])
|
||||
def cancel_batch(batch_id):
|
||||
"""Cancel a batch processing job"""
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
if not processor:
|
||||
return jsonify({'error': 'Batch not found'}), 404
|
||||
|
||||
processor.cancel()
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling batch: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/download-batch-results/<batch_id>')
|
||||
def download_batch_results(batch_id):
|
||||
"""Download batch results as ZIP"""
|
||||
try:
|
||||
processor = get_batch_processor(batch_id)
|
||||
if not processor:
|
||||
# Try to find existing results
|
||||
batch_dir = ensure_results_folder() / f"batch_{batch_id}"
|
||||
if not batch_dir.exists():
|
||||
return jsonify({'error': 'Batch results not found'}), 404
|
||||
|
||||
# Create a temporary processor just for zipping
|
||||
class TempProcessor:
|
||||
def __init__(self, batch_id):
|
||||
self.batch_id = batch_id
|
||||
self.results_dir = batch_dir
|
||||
|
||||
def create_zip_archive(self):
|
||||
import zipfile
|
||||
zip_path = self.results_dir / f"batch_results_{self.batch_id}.zip"
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file_path in self.results_dir.rglob('*'):
|
||||
if file_path.is_file() and file_path != zip_path:
|
||||
arcname = file_path.relative_to(self.results_dir)
|
||||
zipf.write(file_path, arcname)
|
||||
return zip_path
|
||||
|
||||
processor = TempProcessor(batch_id)
|
||||
|
||||
# Create ZIP archive
|
||||
zip_path = processor.create_zip_archive()
|
||||
if not zip_path or not zip_path.exists():
|
||||
return jsonify({'error': 'Failed to create ZIP archive'}), 500
|
||||
|
||||
return send_file(
|
||||
zip_path,
|
||||
as_attachment=True,
|
||||
download_name=f"doctags_batch_{batch_id}.zip"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading batch results: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/retry-page', methods=['POST'])
|
||||
def retry_page():
|
||||
"""Retry processing a failed page"""
|
||||
try:
|
||||
pdf_file = request.form.get('pdf_file')
|
||||
page_num = int(request.form.get('page_num'))
|
||||
adjust = request.form.get('adjust') == 'true'
|
||||
|
||||
if not pdf_file or not os.path.exists(pdf_file):
|
||||
return jsonify({'success': False, 'error': 'Invalid PDF file'}), 400
|
||||
|
||||
# Run the three processing steps
|
||||
results = {'success': True, 'errors': []}
|
||||
|
||||
# Step 1: Analyzer
|
||||
command = (f"python backend/page_treatment/analyzer.py --image {pdf_file} "
|
||||
f"--page {page_num} --start-page {page_num} --end-page {page_num}")
|
||||
success, stdout, stderr = run_command_with_timeout(command, 60)
|
||||
if not success:
|
||||
results['errors'].append(f"Analyzer: {stderr}")
|
||||
results['success'] = False
|
||||
|
||||
# Step 2: Visualizer (only if analyzer succeeded)
|
||||
if results['success']:
|
||||
command = (f"python backend/page_treatment/visualizer.py "
|
||||
f"--doctags results/output.doctags.txt --pdf {pdf_file} --page {page_num}")
|
||||
if adjust:
|
||||
command += " --adjust"
|
||||
success, stdout, stderr = run_command_with_timeout(command, 60)
|
||||
if not success:
|
||||
results['errors'].append(f"Visualizer: {stderr}")
|
||||
results['success'] = False
|
||||
|
||||
# Step 3: Extractor (only if previous steps succeeded)
|
||||
if results['success']:
|
||||
command = (f"python backend/page_treatment/picture_extractor.py "
|
||||
f"--doctags results/output.doctags.txt --pdf {pdf_file} --page {page_num}")
|
||||
if adjust:
|
||||
command += " --adjust"
|
||||
success, stdout, stderr = run_command_with_timeout(command, 60)
|
||||
if not success:
|
||||
results['errors'].append(f"Extractor: {stderr}")
|
||||
# Don't fail completely if just extractor fails
|
||||
|
||||
if results['success']:
|
||||
return jsonify({'success': True})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '; '.join(results['errors'])
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrying page: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/open-results-folder', methods=['POST'])
|
||||
def open_results_folder():
|
||||
"""Open the results folder in the system file explorer"""
|
||||
try:
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
results_dir = ensure_results_folder()
|
||||
|
||||
if platform.system() == 'Windows':
|
||||
os.startfile(str(results_dir))
|
||||
elif platform.system() == 'Darwin': # macOS
|
||||
subprocess.Popen(['open', str(results_dir)])
|
||||
else: # Linux and others
|
||||
subprocess.Popen(['xdg-open', str(results_dir)])
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening results folder: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Could not open folder automatically. ' +
|
||||
f'Please navigate to: {ensure_results_folder()}'
|
||||
})
|
||||
|
||||
# API endpoints for file upload
|
||||
@app.route('/api/upload/doctags', methods=['POST'])
|
||||
def api_upload_doctags():
|
||||
"""Simple API endpoint for getting DocTags from uploaded PDF"""
|
||||
uploaded_file_path = None
|
||||
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'success': False, 'error': 'No file provided'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
success, result = default_handler.save_uploaded_file(file, permanent=True)
|
||||
|
||||
if not success:
|
||||
return jsonify({'success': False, 'error': result.get('error')}), 400
|
||||
|
||||
uploaded_file_path = result['filepath']
|
||||
page_num = request.form.get('page_num', '1')
|
||||
|
||||
# Run analyzer
|
||||
command = (f"python backend/page_treatment/analyzer.py --image {uploaded_file_path} "
|
||||
f"--page {page_num} --start-page {page_num} --end-page {page_num}")
|
||||
|
||||
success, stdout, stderr = run_command_with_timeout(command, 60, "n\n")
|
||||
|
||||
if not success:
|
||||
return jsonify({'success': False, 'error': 'Analysis failed', 'details': stderr}), 500
|
||||
|
||||
# Read doctags
|
||||
doctags_path = ensure_results_folder() / "output.doctags.txt"
|
||||
if not doctags_path.exists():
|
||||
return jsonify({'success': False, 'error': 'DocTags not generated'}), 500
|
||||
|
||||
with open(doctags_path, 'r', encoding='utf-8') as f:
|
||||
doctags_content = f.read()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': result['filename'],
|
||||
'page': int(page_num),
|
||||
'doctags': doctags_content
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in api_upload_doctags: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
finally:
|
||||
# Cleanup uploaded file
|
||||
if uploaded_file_path and os.path.exists(uploaded_file_path):
|
||||
try:
|
||||
os.remove(uploaded_file_path)
|
||||
logger.info(f"Cleaned up: {uploaded_file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup error: {e}")
|
||||
|
||||
# Cleanup task
|
||||
def cleanup_old_files():
|
||||
"""Periodic cleanup of old files"""
|
||||
while True:
|
||||
time.sleep(CLEANUP_INTERVAL)
|
||||
try:
|
||||
# Cleanup uploaded files
|
||||
removed = default_handler.cleanup_old_files(CLEANUP_AGE_HOURS, 'both')
|
||||
if removed > 0:
|
||||
logger.info(f"Cleaned up {removed} old files")
|
||||
|
||||
# Cleanup old tasks
|
||||
with task_lock:
|
||||
cutoff_time = time.time() - (CLEANUP_AGE_HOURS * 3600)
|
||||
old_tasks = [tid for tid, result in task_results.items()
|
||||
if result.get('done') and tid.split('_')[1] < str(int(cutoff_time * 1000))]
|
||||
for tid in old_tasks:
|
||||
del task_results[tid]
|
||||
if old_tasks:
|
||||
logger.info(f"Cleaned up {len(old_tasks)} old tasks")
|
||||
|
||||
# Cleanup batch processors if available
|
||||
if batch_processing_available:
|
||||
cleanup_old_batches(CLEANUP_AGE_HOURS)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup error: {e}")
|
||||
|
||||
# Start cleanup thread
|
||||
cleanup_thread = threading.Thread(target=cleanup_old_files)
|
||||
cleanup_thread.daemon = True
|
||||
cleanup_thread.start()
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info(f"Starting DocTags server on {HOST}:{PORT}")
|
||||
app.run(debug=True, host=HOST, port=PORT)
|
||||
|
|
@ -1,552 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch Processor for DocTags - Handles parallel processing of PDF documents
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import queue
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from backend.utils import ensure_results_folder, run_command_with_timeout, format_duration
|
||||
from backend.config import BATCH_WORKERS, PROCESSING_TIMEOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BatchProcessor:
|
||||
def __init__(self, batch_id, pdf_file, start_page, end_page, options):
|
||||
self.batch_id = batch_id
|
||||
self.pdf_file = pdf_file
|
||||
self.start_page = start_page
|
||||
self.end_page = end_page
|
||||
self.total_pages = end_page - start_page + 1
|
||||
self.options = options
|
||||
|
||||
# State management
|
||||
self.state = {
|
||||
'status': 'initializing',
|
||||
'processed': 0,
|
||||
'total': self.total_pages,
|
||||
'start_time': time.time(),
|
||||
'completed': False,
|
||||
'paused': False,
|
||||
'cancelled': False,
|
||||
'page_statuses': {str(page): 'pending' for page in range(start_page, end_page + 1)},
|
||||
'stages': {
|
||||
'analysis': {'completed': 0, 'total': self.total_pages},
|
||||
'visualization': {'completed': 0, 'total': self.total_pages},
|
||||
'extraction': {'completed': 0, 'total': self.total_pages}
|
||||
},
|
||||
'results': {
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'totalImages': 0,
|
||||
'failedPages': []
|
||||
},
|
||||
'logs': []
|
||||
}
|
||||
|
||||
# Threading
|
||||
self.lock = threading.Lock()
|
||||
self.pause_event = threading.Event()
|
||||
self.pause_event.set() # Start unpaused
|
||||
|
||||
# Create batch results directory
|
||||
self.results_dir = ensure_results_folder() / f"batch_{batch_id}"
|
||||
self.results_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Log file
|
||||
self.log_file = self.results_dir / "batch_processing.log"
|
||||
|
||||
def log_message(self, message, level='info'):
|
||||
"""Add a log message to the state"""
|
||||
timestamp = datetime.now().isoformat()
|
||||
log_entry = {
|
||||
'timestamp': timestamp,
|
||||
'level': level,
|
||||
'message': message
|
||||
}
|
||||
|
||||
with self.lock:
|
||||
self.state['logs'].append(log_entry)
|
||||
# Keep only last 100 log entries
|
||||
if len(self.state['logs']) > 100:
|
||||
self.state['logs'] = self.state['logs'][-100:]
|
||||
|
||||
# Write to log file
|
||||
with open(self.log_file, 'a') as f:
|
||||
f.write(f"[{timestamp}] [{level.upper()}] {message}\n")
|
||||
|
||||
logger.info(f"[{level}] {message}")
|
||||
|
||||
def update_page_status(self, page_num, status):
|
||||
"""Update the status of a specific page"""
|
||||
with self.lock:
|
||||
self.state['page_statuses'][str(page_num)] = status
|
||||
|
||||
def update_stage_progress(self, stage, increment=1):
|
||||
"""Update progress for a specific stage"""
|
||||
with self.lock:
|
||||
self.state['stages'][stage]['completed'] += increment
|
||||
|
||||
def process_page(self, page_num):
|
||||
"""Process a single page through all stages"""
|
||||
try:
|
||||
# Check if paused or cancelled
|
||||
self.pause_event.wait()
|
||||
if self.state['cancelled']:
|
||||
return False
|
||||
|
||||
self.log_message(f"Starting processing for page {page_num}")
|
||||
self.update_page_status(page_num, 'processing')
|
||||
|
||||
# Stage 1: Analysis
|
||||
if not self.run_analyzer(page_num):
|
||||
raise Exception("Analyzer failed")
|
||||
self.update_stage_progress('analysis')
|
||||
|
||||
# Check pause/cancel
|
||||
self.pause_event.wait()
|
||||
if self.state['cancelled']:
|
||||
return False
|
||||
|
||||
# Stage 2: Visualization
|
||||
if not self.run_visualizer(page_num):
|
||||
raise Exception("Visualizer failed")
|
||||
self.update_stage_progress('visualization')
|
||||
|
||||
# Check pause/cancel
|
||||
self.pause_event.wait()
|
||||
if self.state['cancelled']:
|
||||
return False
|
||||
|
||||
# Stage 3: Extraction
|
||||
image_count = self.run_extractor(page_num)
|
||||
self.update_stage_progress('extraction')
|
||||
|
||||
# Update results
|
||||
with self.lock:
|
||||
self.state['results']['successful'] += 1
|
||||
self.state['results']['totalImages'] += image_count
|
||||
self.state['processed'] += 1
|
||||
|
||||
self.update_page_status(page_num, 'completed')
|
||||
self.log_message(f"Successfully processed page {page_num}", 'success')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Handle failure
|
||||
with self.lock:
|
||||
self.state['results']['failed'] += 1
|
||||
self.state['results']['failedPages'].append({
|
||||
'pageNum': page_num,
|
||||
'reason': str(e)
|
||||
})
|
||||
self.state['processed'] += 1
|
||||
|
||||
self.update_page_status(page_num, 'failed')
|
||||
self.log_message(f"Failed to process page {page_num}: {str(e)}", 'error')
|
||||
return False
|
||||
|
||||
def run_analyzer(self, page_num):
|
||||
"""Run the analyzer for a specific page"""
|
||||
try:
|
||||
# Use page-specific output to avoid conflicts
|
||||
command = (f"python backend/page_treatment/analyzer.py "
|
||||
f"--image {self.pdf_file} --page {page_num} "
|
||||
f"--start-page {page_num} --end-page {page_num}")
|
||||
|
||||
self.log_message(f"Running analyzer for page {page_num}")
|
||||
|
||||
success, stdout, stderr = run_command_with_timeout(command, PROCESSING_TIMEOUT)
|
||||
|
||||
if not success:
|
||||
raise Exception(f"Analyzer failed: {stderr}")
|
||||
|
||||
# The analyzer will create either output.doctags.txt or output_pageN.doctags.txt
|
||||
# Check both locations
|
||||
results_dir = ensure_results_folder()
|
||||
possible_paths = [
|
||||
results_dir / "output.doctags.txt",
|
||||
results_dir / f"output_page{page_num}.doctags.txt"
|
||||
]
|
||||
|
||||
doctags_src = None
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
doctags_src = path
|
||||
break
|
||||
|
||||
if not doctags_src:
|
||||
raise Exception("DocTags file not generated")
|
||||
|
||||
# Copy to batch directory with page-specific name
|
||||
doctags_dst = self.results_dir / f"page_{page_num}.doctags.txt"
|
||||
shutil.copy2(doctags_src, doctags_dst)
|
||||
|
||||
# Verify the file has content
|
||||
with open(doctags_dst, 'r') as f:
|
||||
content = f.read().strip()
|
||||
if not content or '<doctag>' not in content:
|
||||
raise Exception("DocTags file is empty or invalid")
|
||||
|
||||
self.log_message(f"DocTags saved for page {page_num}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_message(f"Analyzer error for page {page_num}: {str(e)}", 'error')
|
||||
return False
|
||||
|
||||
def run_visualizer(self, page_num):
|
||||
"""Run the visualizer for a specific page"""
|
||||
try:
|
||||
# Ensure correct doctags file is in place
|
||||
doctags_path = ensure_results_folder() / "output.doctags.txt"
|
||||
page_doctags = self.results_dir / f"page_{page_num}.doctags.txt"
|
||||
|
||||
if page_doctags.exists():
|
||||
shutil.copy2(page_doctags, doctags_path)
|
||||
|
||||
command = (f"python backend/page_treatment/visualizer.py "
|
||||
f"--doctags {doctags_path} --pdf {self.pdf_file} --page {page_num}")
|
||||
|
||||
if self.options.get('adjust', True):
|
||||
command += " --adjust"
|
||||
|
||||
self.log_message(f"Running visualizer for page {page_num}")
|
||||
|
||||
success, stdout, stderr = run_command_with_timeout(command, PROCESSING_TIMEOUT)
|
||||
|
||||
if not success:
|
||||
raise Exception(f"Visualizer failed: {stderr}")
|
||||
|
||||
# Copy visualization to batch directory
|
||||
viz_src = ensure_results_folder() / f"visualization_page_{page_num}.png"
|
||||
viz_dst = self.results_dir / f"visualization_page_{page_num}.png"
|
||||
|
||||
if viz_src.exists():
|
||||
shutil.copy2(viz_src, viz_dst)
|
||||
self.log_message(f"Visualization saved for page {page_num}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.log_message(f"Visualizer error for page {page_num}: {str(e)}", 'error')
|
||||
return False
|
||||
|
||||
def run_extractor(self, page_num):
|
||||
"""Run the picture extractor for a specific page"""
|
||||
try:
|
||||
# Ensure correct doctags file is in place
|
||||
doctags_path = ensure_results_folder() / "output.doctags.txt"
|
||||
page_doctags = self.results_dir / f"page_{page_num}.doctags.txt"
|
||||
|
||||
if page_doctags.exists():
|
||||
shutil.copy2(page_doctags, doctags_path)
|
||||
|
||||
command = (f"python backend/page_treatment/picture_extractor.py "
|
||||
f"--doctags {doctags_path} --pdf {self.pdf_file} --page {page_num}")
|
||||
|
||||
if self.options.get('adjust', True):
|
||||
command += " --adjust"
|
||||
|
||||
self.log_message(f"Running extractor for page {page_num}")
|
||||
|
||||
success, stdout, stderr = run_command_with_timeout(command, PROCESSING_TIMEOUT)
|
||||
|
||||
if not success:
|
||||
self.log_message(f"Extractor warning for page {page_num}: {stderr}", 'warning')
|
||||
|
||||
# Count and copy extracted images
|
||||
image_count = 0
|
||||
pics_src = ensure_results_folder() / "pictures"
|
||||
pics_dst = self.results_dir / f"pictures_page_{page_num}"
|
||||
|
||||
if pics_src.exists():
|
||||
# Copy to batch directory
|
||||
if pics_dst.exists():
|
||||
shutil.rmtree(pics_dst)
|
||||
shutil.copytree(pics_src, pics_dst)
|
||||
|
||||
# Also copy for web interface
|
||||
pics_web = ensure_results_folder() / f"pictures_page_{page_num}"
|
||||
if pics_web.exists():
|
||||
shutil.rmtree(pics_web)
|
||||
shutil.copytree(pics_src, pics_web)
|
||||
|
||||
# Count PNG files
|
||||
image_count = len(list(pics_dst.glob("*.png")))
|
||||
self.log_message(f"Extracted {image_count} images from page {page_num}")
|
||||
|
||||
return image_count
|
||||
|
||||
except Exception as e:
|
||||
self.log_message(f"Extractor error for page {page_num}: {str(e)}", 'error')
|
||||
return 0
|
||||
|
||||
def run(self):
|
||||
"""Main batch processing loop"""
|
||||
try:
|
||||
self.state['status'] = 'processing'
|
||||
self.log_message(f"Starting batch processing for {self.pdf_file} "
|
||||
f"(pages {self.start_page}-{self.end_page})")
|
||||
|
||||
# Determine number of workers
|
||||
max_workers = BATCH_WORKERS if self.options.get('parallel', True) else 1
|
||||
|
||||
# Create page list
|
||||
pages = list(range(self.start_page, self.end_page + 1))
|
||||
|
||||
# Process pages
|
||||
if max_workers > 1:
|
||||
# Parallel processing
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {executor.submit(self.process_page, page): page
|
||||
for page in pages}
|
||||
|
||||
for future in as_completed(futures):
|
||||
if self.state['cancelled']:
|
||||
executor.shutdown(wait=False)
|
||||
break
|
||||
|
||||
page = futures[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
self.log_message(f"Unexpected error processing page {page}: {str(e)}",
|
||||
'error')
|
||||
else:
|
||||
# Sequential processing
|
||||
for page in pages:
|
||||
if self.state['cancelled']:
|
||||
break
|
||||
self.process_page(page)
|
||||
|
||||
# Generate report if requested
|
||||
if self.options.get('generate_report', True) and not self.state['cancelled']:
|
||||
self.generate_report()
|
||||
|
||||
# Update final state
|
||||
with self.lock:
|
||||
self.state['completed'] = True
|
||||
self.state['status'] = 'cancelled' if self.state['cancelled'] else 'completed'
|
||||
|
||||
duration = time.time() - self.state['start_time']
|
||||
self.log_message(f"Batch processing completed in {format_duration(duration)}",
|
||||
'success')
|
||||
|
||||
except Exception as e:
|
||||
self.log_message(f"Critical error in batch processing: {str(e)}", 'error')
|
||||
with self.lock:
|
||||
self.state['completed'] = True
|
||||
self.state['status'] = 'error'
|
||||
|
||||
def generate_report(self):
|
||||
"""Generate HTML report of batch processing results"""
|
||||
try:
|
||||
self.log_message("Generating batch processing report")
|
||||
|
||||
duration = time.time() - self.state['start_time']
|
||||
success_rate = (self.state['results']['successful'] / self.total_pages * 100
|
||||
if self.total_pages > 0 else 0)
|
||||
|
||||
# Create report HTML
|
||||
report_html = self._create_report_html(duration, success_rate)
|
||||
|
||||
# Save report
|
||||
report_path = self.results_dir / "report.html"
|
||||
with open(report_path, 'w') as f:
|
||||
f.write(report_html)
|
||||
|
||||
self.log_message("Report generated successfully")
|
||||
|
||||
except Exception as e:
|
||||
self.log_message(f"Error generating report: {str(e)}", 'error')
|
||||
|
||||
def _create_report_html(self, duration, success_rate):
|
||||
"""Create HTML content for the report"""
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Batch Processing Report - {self.pdf_file}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }}
|
||||
.container {{ max-width: 1200px; margin: 0 auto; background: white;
|
||||
padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
|
||||
h1 {{ color: #2c3e50; }}
|
||||
.stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px; margin: 30px 0; }}
|
||||
.stat-box {{ background: #ecf0f1; padding: 20px; border-radius: 8px; text-align: center; }}
|
||||
.stat-value {{ font-size: 2.5em; font-weight: bold; color: #2c3e50; }}
|
||||
.success {{ color: #27ae60; }}
|
||||
.error {{ color: #e74c3c; }}
|
||||
table {{ width: 100%; border-collapse: collapse; margin: 20px 0; }}
|
||||
th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ecf0f1; }}
|
||||
th {{ background: #34495e; color: white; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Batch Processing Report</h1>
|
||||
<p>Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{self.total_pages}</div>
|
||||
<div>Total Pages</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value success">{self.state['results']['successful']}</div>
|
||||
<div>Successful</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value error">{self.state['results']['failed']}</div>
|
||||
<div>Failed</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{self.state['results']['totalImages']}</div>
|
||||
<div>Images Extracted</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{format_duration(duration)}</div>
|
||||
<div>Processing Time</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value">{success_rate:.1f}%</div>
|
||||
<div>Success Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Add failed pages if any
|
||||
if self.state['results']['failedPages']:
|
||||
html += """
|
||||
<h2>Failed Pages</h2>
|
||||
<table>
|
||||
<tr><th>Page Number</th><th>Reason</th></tr>
|
||||
"""
|
||||
for failed in self.state['results']['failedPages']:
|
||||
html += f"<tr><td>{failed['pageNum']}</td><td>{failed['reason']}</td></tr>\n"
|
||||
html += "</table>\n"
|
||||
|
||||
html += """
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
|
||||
def pause(self):
|
||||
"""Pause the batch processing"""
|
||||
self.pause_event.clear()
|
||||
self.state['paused'] = True
|
||||
self.log_message("Batch processing paused")
|
||||
|
||||
def resume(self):
|
||||
"""Resume the batch processing"""
|
||||
self.pause_event.set()
|
||||
self.state['paused'] = False
|
||||
self.log_message("Batch processing resumed")
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel the batch processing"""
|
||||
self.state['cancelled'] = True
|
||||
self.pause_event.set()
|
||||
self.log_message("Batch processing cancelled")
|
||||
|
||||
def get_state(self):
|
||||
"""Get current state with calculated fields"""
|
||||
with self.lock:
|
||||
state = self.state.copy()
|
||||
|
||||
# Calculate ETA
|
||||
if state['processed'] > 0 and not state['completed']:
|
||||
elapsed = time.time() - state['start_time']
|
||||
rate = state['processed'] / elapsed
|
||||
remaining = state['total'] - state['processed']
|
||||
eta = remaining / rate if rate > 0 else 0
|
||||
state['eta'] = eta * 1000 # Convert to milliseconds
|
||||
else:
|
||||
state['eta'] = 0
|
||||
|
||||
return state
|
||||
|
||||
def create_zip_archive(self):
|
||||
"""Create ZIP archive of all results"""
|
||||
try:
|
||||
zip_path = self.results_dir / f"batch_results_{self.batch_id}.zip"
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file_path in self.results_dir.rglob('*'):
|
||||
if file_path.is_file() and file_path != zip_path:
|
||||
arcname = file_path.relative_to(self.results_dir)
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
self.log_message(f"Created ZIP archive: {zip_path}")
|
||||
return zip_path
|
||||
|
||||
except Exception as e:
|
||||
self.log_message(f"Error creating ZIP archive: {str(e)}", 'error')
|
||||
return None
|
||||
|
||||
|
||||
# Global batch processors storage
|
||||
batch_processors = {}
|
||||
batch_lock = threading.Lock()
|
||||
|
||||
|
||||
def start_batch_processing(batch_id, pdf_file, start_page, end_page, options):
|
||||
"""Start a new batch processing job"""
|
||||
try:
|
||||
processor = BatchProcessor(batch_id, pdf_file, start_page, end_page, options)
|
||||
|
||||
with batch_lock:
|
||||
batch_processors[batch_id] = processor
|
||||
|
||||
# Start processing in a separate thread
|
||||
thread = threading.Thread(target=processor.run)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting batch processing: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def get_batch_processor(batch_id):
|
||||
"""Get a batch processor by ID"""
|
||||
with batch_lock:
|
||||
return batch_processors.get(batch_id)
|
||||
|
||||
|
||||
def cleanup_old_batches(max_age_hours=24):
|
||||
"""Clean up old batch processors"""
|
||||
current_time = time.time()
|
||||
max_age_seconds = max_age_hours * 3600
|
||||
|
||||
with batch_lock:
|
||||
to_remove = []
|
||||
for batch_id, processor in batch_processors.items():
|
||||
if processor.state['completed']:
|
||||
age = current_time - processor.state['start_time']
|
||||
if age > max_age_seconds:
|
||||
to_remove.append(batch_id)
|
||||
|
||||
for batch_id in to_remove:
|
||||
del batch_processors[batch_id]
|
||||
logger.info(f"Cleaned up old batch processor: {batch_id}")
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration settings for DocTags
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Application settings
|
||||
APP_NAME = "DocTags Intelligence Suite"
|
||||
APP_VERSION = "1.0.0"
|
||||
DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'
|
||||
|
||||
# Server settings
|
||||
HOST = '0.0.0.0'
|
||||
PORT = 5000
|
||||
MAX_CONTENT_LENGTH = 100 * 1024 * 1024 # 100MB
|
||||
|
||||
# Processing settings
|
||||
DEFAULT_DPI = 200
|
||||
PREVIEW_DPI = 150
|
||||
DEFAULT_GRID_SIZE = 500
|
||||
MAX_IMAGE_WIDTH = 1200
|
||||
DEFAULT_PAGE = 1
|
||||
PROCESSING_TIMEOUT = 300 # 5 minutes
|
||||
BATCH_WORKERS = 4
|
||||
|
||||
# File settings
|
||||
ALLOWED_EXTENSIONS = {'pdf'}
|
||||
RESULTS_DIR = 'results'
|
||||
UPLOAD_DIR = 'uploads'
|
||||
TEMP_DIR = 'temp_uploads'
|
||||
|
||||
# Cleanup settings
|
||||
CLEANUP_AGE_HOURS = 24
|
||||
CLEANUP_INTERVAL = 3600 # 1 hour
|
||||
|
||||
# Model settings
|
||||
MODEL_PATH = "ds4sd/SmolDocling-256M-preview"
|
||||
MAX_TOKENS = 4096
|
||||
|
||||
# Zone colors for visualization
|
||||
ZONE_COLORS = {
|
||||
'section_header_level_1': (255, 87, 34), # Orange
|
||||
'text': (33, 150, 243), # Blue
|
||||
'picture': (76, 175, 80), # Green
|
||||
'table': (156, 39, 176), # Purple
|
||||
'page_header': (255, 193, 7), # Amber
|
||||
'page_footer': (121, 85, 72), # Brown
|
||||
'default': (96, 125, 139) # Blue Grey
|
||||
}
|
||||
|
||||
# Logging configuration
|
||||
LOGGING_CONFIG = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'default': {
|
||||
'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'default',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'level': 'INFO',
|
||||
'handlers': ['console'],
|
||||
},
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multipart File Upload Handler for DocTags
|
||||
Handles file upload processing and validation
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.datastructures import FileStorage
|
||||
import tempfile
|
||||
import shutil
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MultipartHandler:
|
||||
"""Handle multipart file uploads with validation and storage"""
|
||||
|
||||
ALLOWED_EXTENSIONS = {'pdf'}
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
UPLOAD_FOLDER = 'uploads'
|
||||
TEMP_FOLDER = 'temp_uploads'
|
||||
|
||||
def __init__(self, upload_folder: str = None, temp_folder: str = None):
|
||||
self.upload_folder = Path(upload_folder or self.UPLOAD_FOLDER)
|
||||
self.temp_folder = Path(temp_folder or self.TEMP_FOLDER)
|
||||
self._ensure_folders()
|
||||
|
||||
def _ensure_folders(self):
|
||||
"""Ensure upload and temp folders exist"""
|
||||
for folder in [self.upload_folder, self.temp_folder]:
|
||||
if not folder.exists():
|
||||
folder.mkdir(parents=True)
|
||||
logger.info(f"Created folder: {folder}")
|
||||
|
||||
def allowed_file(self, filename: str) -> bool:
|
||||
"""Check if file extension is allowed"""
|
||||
return '.' in filename and \
|
||||
filename.rsplit('.', 1)[1].lower() in self.ALLOWED_EXTENSIONS
|
||||
|
||||
def validate_file(self, file: FileStorage) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate uploaded file
|
||||
Returns: (is_valid, error_message)
|
||||
"""
|
||||
if not file:
|
||||
return False, "No file provided"
|
||||
|
||||
if file.filename == '':
|
||||
return False, "No file selected"
|
||||
|
||||
if not self.allowed_file(file.filename):
|
||||
return False, f"Invalid file type. Allowed types: {', '.join(self.ALLOWED_EXTENSIONS)}"
|
||||
|
||||
# Check file size (if possible)
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0) # Reset file pointer
|
||||
|
||||
if file_size > self.MAX_FILE_SIZE:
|
||||
return False, f"File too large. Maximum size: {self.MAX_FILE_SIZE / (1024*1024):.1f}MB"
|
||||
|
||||
return True, None
|
||||
|
||||
def save_uploaded_file(self, file: FileStorage, permanent: bool = True) -> Tuple[bool, Dict]:
|
||||
"""
|
||||
Save uploaded file
|
||||
|
||||
Args:
|
||||
file: The uploaded file
|
||||
permanent: If True, save to uploads folder; if False, save to temp folder
|
||||
|
||||
Returns:
|
||||
(success, result_dict) where result_dict contains:
|
||||
- filepath: Path to saved file
|
||||
- filename: Original filename
|
||||
- unique_filename: Saved filename
|
||||
- size: File size in bytes
|
||||
- error: Error message if failed
|
||||
"""
|
||||
# Validate file
|
||||
is_valid, error_msg = self.validate_file(file)
|
||||
if not is_valid:
|
||||
return False, {'error': error_msg}
|
||||
|
||||
try:
|
||||
# Secure the filename
|
||||
original_filename = secure_filename(file.filename)
|
||||
timestamp = int(time.time() * 1000) # Millisecond timestamp
|
||||
|
||||
# Create unique filename
|
||||
name_parts = original_filename.rsplit('.', 1)
|
||||
if len(name_parts) == 2:
|
||||
unique_filename = f"{name_parts[0]}_{timestamp}.{name_parts[1]}"
|
||||
else:
|
||||
unique_filename = f"{original_filename}_{timestamp}"
|
||||
|
||||
# Determine save location
|
||||
save_folder = self.upload_folder if permanent else self.temp_folder
|
||||
filepath = save_folder / unique_filename
|
||||
|
||||
# Save file
|
||||
file.save(str(filepath))
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
logger.info(f"Saved file: {filepath} (size: {file_size} bytes)")
|
||||
|
||||
return True, {
|
||||
'filepath': str(filepath),
|
||||
'filename': original_filename,
|
||||
'unique_filename': unique_filename,
|
||||
'size': file_size,
|
||||
'permanent': permanent
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving file: {str(e)}")
|
||||
return False, {'error': f"Failed to save file: {str(e)}"}
|
||||
|
||||
def save_to_temp(self, file: FileStorage) -> Tuple[bool, Dict]:
|
||||
"""Save file to temporary folder"""
|
||||
return self.save_uploaded_file(file, permanent=False)
|
||||
|
||||
def move_to_permanent(self, temp_filepath: str) -> Tuple[bool, Dict]:
|
||||
"""Move file from temp to permanent storage"""
|
||||
try:
|
||||
temp_path = Path(temp_filepath)
|
||||
if not temp_path.exists():
|
||||
return False, {'error': 'Temporary file not found'}
|
||||
|
||||
permanent_path = self.upload_folder / temp_path.name
|
||||
shutil.move(str(temp_path), str(permanent_path))
|
||||
|
||||
return True, {
|
||||
'filepath': str(permanent_path),
|
||||
'moved_from': str(temp_path)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving file: {str(e)}")
|
||||
return False, {'error': f"Failed to move file: {str(e)}"}
|
||||
|
||||
def cleanup_old_files(self, max_age_hours: int = 24, folder: str = 'both'):
|
||||
"""
|
||||
Remove old files from upload/temp folders
|
||||
|
||||
Args:
|
||||
max_age_hours: Maximum age of files in hours
|
||||
folder: 'uploads', 'temp', or 'both'
|
||||
"""
|
||||
folders_to_clean = []
|
||||
if folder in ['uploads', 'both']:
|
||||
folders_to_clean.append(self.upload_folder)
|
||||
if folder in ['temp', 'both']:
|
||||
folders_to_clean.append(self.temp_folder)
|
||||
|
||||
current_time = time.time()
|
||||
max_age_seconds = max_age_hours * 3600
|
||||
removed_count = 0
|
||||
|
||||
for folder_path in folders_to_clean:
|
||||
if not folder_path.exists():
|
||||
continue
|
||||
|
||||
for file_path in folder_path.glob('*.pdf'):
|
||||
try:
|
||||
file_age = current_time - file_path.stat().st_mtime
|
||||
if file_age > max_age_seconds:
|
||||
file_path.unlink()
|
||||
removed_count += 1
|
||||
logger.info(f"Deleted old file: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting file {file_path}: {str(e)}")
|
||||
|
||||
return removed_count
|
||||
|
||||
def get_file_info(self, filepath: str) -> Optional[Dict]:
|
||||
"""Get information about an uploaded file"""
|
||||
try:
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
stats = path.stat()
|
||||
return {
|
||||
'filepath': str(path),
|
||||
'filename': path.name,
|
||||
'size': stats.st_size,
|
||||
'created': stats.st_ctime,
|
||||
'modified': stats.st_mtime,
|
||||
'exists': True
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting file info: {str(e)}")
|
||||
return None
|
||||
|
||||
def create_multipart_response(self, success: bool, data: Dict) -> Dict:
|
||||
"""Create standardized response for multipart operations"""
|
||||
response = {
|
||||
'success': success,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
if success:
|
||||
response['data'] = data
|
||||
else:
|
||||
response['error'] = data.get('error', 'Unknown error')
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# Create a default instance
|
||||
default_handler = MultipartHandler()
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = [
|
||||
# "transformers>=4.50",
|
||||
# "torch",
|
||||
# "pillow",
|
||||
# "requests",
|
||||
# "argparse",
|
||||
# "pdf2image",
|
||||
# "docling_core",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
import requests
|
||||
from PIL import Image
|
||||
from pdf2image import convert_from_bytes
|
||||
import torch
|
||||
from transformers import AutoProcessor, AutoModelForVision2Seq
|
||||
from docling_core.types.doc import DoclingDocument
|
||||
from docling_core.types.doc.document import DocTagsDocument
|
||||
|
||||
# Add parent directory to path for imports
|
||||
import sys
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from backend.utils import ensure_results_folder, load_pdf_page
|
||||
from backend.config import MODEL_PATH, MAX_TOKENS, DEFAULT_DPI
|
||||
|
||||
def parse_arguments():
|
||||
results_dir = ensure_results_folder()
|
||||
|
||||
parser = argparse.ArgumentParser(description='Convert an image or PDF to docling format')
|
||||
parser.add_argument('--image', '-i', type=str, required=True,
|
||||
help='Path to local image file, PDF file, or URL')
|
||||
parser.add_argument('--prompt', '-p', type=str, default="Convert this page to docling.",
|
||||
help='Prompt for the model')
|
||||
parser.add_argument('--output', '-o', type=str, default=str(results_dir / "output.html"),
|
||||
help='Output file path')
|
||||
parser.add_argument('--page', type=int, default=1,
|
||||
help='Page number to process for PDF files (starts at 1)')
|
||||
parser.add_argument('--dpi', type=int, default=DEFAULT_DPI,
|
||||
help='DPI for PDF rendering')
|
||||
parser.add_argument('--start-page', type=int, default=1,
|
||||
help='Start processing PDF from this page number')
|
||||
parser.add_argument('--end-page', type=int, default=None,
|
||||
help='Stop processing PDF at this page number')
|
||||
return parser.parse_args()
|
||||
|
||||
def load_image(image_path, page_num=1, dpi=DEFAULT_DPI):
|
||||
if urlparse(image_path).scheme in ['http', 'https']:
|
||||
response = requests.get(image_path, stream=True, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
if image_path.lower().endswith('.pdf') or response.headers.get('Content-Type') == 'application/pdf':
|
||||
print(f"Converting PDF from URL (page {page_num})...")
|
||||
pdf_images = convert_from_bytes(response.content, dpi=dpi, first_page=page_num, last_page=page_num)
|
||||
if not pdf_images:
|
||||
raise Exception(f"Could not extract page {page_num} from PDF")
|
||||
return pdf_images[0].convert("RGB")
|
||||
else:
|
||||
return Image.open(response.raw).convert("RGB")
|
||||
else:
|
||||
image_path = Path(image_path)
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"File not found: {image_path}")
|
||||
|
||||
if image_path.suffix.lower() == '.pdf':
|
||||
return load_pdf_page(str(image_path), page_num, dpi).convert("RGB")
|
||||
else:
|
||||
return Image.open(image_path).convert("RGB")
|
||||
|
||||
def process_page(model, processor, args, pil_image, page_num=1):
|
||||
results_dir = ensure_results_folder()
|
||||
|
||||
if args.start_page == args.end_page and args.start_page == page_num:
|
||||
doctags_path = results_dir / "output.doctags.txt"
|
||||
output_path = results_dir / "output.html"
|
||||
else:
|
||||
doctags_path = results_dir / f"output_page{page_num}.doctags.txt"
|
||||
output_path = results_dir / f"output_page{page_num}.html"
|
||||
|
||||
print(f"Processing page {page_num}")
|
||||
|
||||
# Préparer les messages
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image"},
|
||||
{"type": "text", "text": args.prompt}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
prompt = processor.apply_chat_template(messages, add_generation_prompt=True)
|
||||
|
||||
device = next(model.parameters()).device
|
||||
inputs = processor(text=prompt, images=[pil_image], return_tensors="pt").to(device)
|
||||
|
||||
# Génération
|
||||
generated_ids = model.generate(**inputs, max_new_tokens=MAX_TOKENS)
|
||||
prompt_length = inputs.input_ids.shape[1]
|
||||
trimmed_generated_ids = generated_ids[:, prompt_length:]
|
||||
|
||||
doctags = processor.batch_decode(trimmed_generated_ids, skip_special_tokens=False)[0].lstrip()
|
||||
with open(doctags_path, "w", encoding="utf-8") as f:
|
||||
f.write(doctags)
|
||||
print(f"DocTags saved to {doctags_path}")
|
||||
|
||||
doctags_doc = DocTagsDocument.from_doctags_and_image_pairs([doctags], [pil_image])
|
||||
doc = DoclingDocument.load_from_doctags(doctags_doc, document_name=f"Page {page_num}")
|
||||
html = doc.export_to_html()
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"HTML exported to {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
print("Loading model and processor...")
|
||||
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
model = AutoModelForVision2Seq.from_pretrained(
|
||||
MODEL_PATH,
|
||||
torch_dtype=torch.bfloat16,
|
||||
_attn_implementation="flash_attention_2" if device.type == "cuda" else "eager"
|
||||
).to(device)
|
||||
processor = AutoProcessor.from_pretrained(MODEL_PATH)
|
||||
|
||||
start_page = args.start_page
|
||||
end_page = args.end_page or args.page
|
||||
|
||||
for page_num in range(start_page, end_page + 1):
|
||||
pil_image = load_image(args.image, page_num=page_num, dpi=args.dpi)
|
||||
process_page(model, processor, args, pil_image, page_num)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
DocTags Picture Extractor - Extract <picture> elements from DocTags.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
# Add parent directory to path for imports
|
||||
import sys
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from backend.utils import (ensure_results_folder, load_pdf_page,
|
||||
normalize_coordinates, auto_adjust_coordinates,
|
||||
validate_coordinates)
|
||||
from backend.config import DEFAULT_DPI, MAX_IMAGE_WIDTH, DEFAULT_GRID_SIZE
|
||||
|
||||
# Regular expression to extract picture location data
|
||||
PICTURE_PATTERN = r'<picture>.*?<loc_(\d+)><loc_(\d+)><loc_(\d+)><loc_(\d+)>(.*?)</picture>'
|
||||
|
||||
def parse_arguments():
|
||||
"""Parse command line arguments."""
|
||||
results_dir = ensure_results_folder()
|
||||
|
||||
parser = argparse.ArgumentParser(description='Extract pictures from DocTags format')
|
||||
parser.add_argument('--doctags', '-d', type=str, required=True,
|
||||
help='Path to DocTags file')
|
||||
parser.add_argument('--pdf', '-p', type=str, required=True,
|
||||
help='Path to original PDF file')
|
||||
parser.add_argument('--page', type=int, default=1,
|
||||
help='Page number in PDF (starts at 1)')
|
||||
parser.add_argument('--output', '-o', type=str, default=str(results_dir / "pictures"),
|
||||
help='Output directory for extracted pictures')
|
||||
parser.add_argument('--dpi', type=int, default=DEFAULT_DPI,
|
||||
help='DPI for PDF rendering')
|
||||
parser.add_argument('--max-width', type=int, default=MAX_IMAGE_WIDTH,
|
||||
help='Maximum width of output images in pixels')
|
||||
parser.add_argument('--adjust', action='store_true',
|
||||
help='Try to automatically adjust scaling')
|
||||
parser.add_argument('--margin', type=int, default=0,
|
||||
help='Add margin around extracted pictures in pixels')
|
||||
return parser.parse_args()
|
||||
|
||||
def extract_pictures_from_doctags(doctags_path):
|
||||
"""Parse DocTags file and extract picture elements with their coordinates."""
|
||||
if not os.path.exists(doctags_path):
|
||||
raise FileNotFoundError(f"DocTags file not found: {doctags_path}")
|
||||
|
||||
with open(doctags_path, 'r', encoding='utf-8') as f:
|
||||
doctags_content = f.read()
|
||||
|
||||
pictures = []
|
||||
picture_matches = re.finditer(PICTURE_PATTERN, doctags_content, re.DOTALL)
|
||||
|
||||
for i, match in enumerate(picture_matches):
|
||||
x1, y1, x2, y2, caption = match.groups()
|
||||
|
||||
# Clean caption
|
||||
clean_caption = re.sub(r'<loc_\d+>', '', caption).strip()
|
||||
|
||||
pictures.append({
|
||||
'id': i + 1,
|
||||
'x1': int(x1), 'y1': int(y1),
|
||||
'x2': int(x2), 'y2': int(y2),
|
||||
'caption': clean_caption
|
||||
})
|
||||
|
||||
return pictures
|
||||
|
||||
def extract_and_save_pictures(image, pictures, output_dir, max_width, margin):
|
||||
"""Extract picture regions from the image and save them as separate files."""
|
||||
output_path = ensure_results_folder(output_dir)
|
||||
saved_files = []
|
||||
|
||||
for picture in pictures:
|
||||
try:
|
||||
# Add margin to coordinates
|
||||
x1 = max(0, picture['x1'] - margin)
|
||||
y1 = max(0, picture['y1'] - margin)
|
||||
x2 = min(image.width, picture['x2'] + margin)
|
||||
y2 = min(image.height, picture['y2'] + margin)
|
||||
|
||||
# Validate coordinates
|
||||
if not validate_coordinates(x1, y1, x2, y2, image.width, image.height):
|
||||
print(f"Warning: Invalid coordinates for picture {picture['id']}")
|
||||
continue
|
||||
|
||||
# Crop the image
|
||||
cropped_img = image.crop((x1, y1, x2, y2))
|
||||
|
||||
# Resize if necessary
|
||||
if cropped_img.width > max_width:
|
||||
ratio = max_width / cropped_img.width
|
||||
new_height = int(cropped_img.height * ratio)
|
||||
cropped_img = cropped_img.resize((max_width, new_height), Image.LANCZOS)
|
||||
|
||||
# Generate filename
|
||||
if picture['caption']:
|
||||
safe_caption = re.sub(r'[^\w\s-]', '', picture['caption'])[:30].strip().replace(' ', '_').lower()
|
||||
filename = f"picture_{picture['id']}_{safe_caption}.png"
|
||||
else:
|
||||
filename = f"picture_{picture['id']}.png"
|
||||
|
||||
# Save the image
|
||||
output_file = output_path / filename
|
||||
cropped_img.save(output_file, format="PNG")
|
||||
|
||||
# Save caption if available
|
||||
if picture['caption']:
|
||||
caption_file = output_path / f"{output_file.stem}.txt"
|
||||
with open(caption_file, 'w', encoding='utf-8') as f:
|
||||
f.write(picture['caption'])
|
||||
|
||||
print(f"Saved picture {picture['id']} to {output_file}")
|
||||
saved_files.append(output_file)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing picture {picture['id']}: {e}")
|
||||
|
||||
return saved_files
|
||||
|
||||
def create_html_index(pictures, saved_files, pdf_name, page_num, output_dir):
|
||||
"""Create an HTML index file of all extracted pictures."""
|
||||
output_path = Path(output_dir)
|
||||
index_file = output_path / "index.html"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Extracted Pictures from {pdf_name} - Page {page_num}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }}
|
||||
h1 {{ color: #333; }}
|
||||
.gallery {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
.picture-card {{
|
||||
background-color: white;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}}
|
||||
.picture-card img {{
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}}
|
||||
.picture-info {{
|
||||
padding: 15px;
|
||||
}}
|
||||
.no-pictures {{
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
color: #777;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Extracted Pictures from {pdf_name} - Page {page_num}</h1>
|
||||
<p>Total pictures found: {len(pictures)}</p>
|
||||
"""
|
||||
|
||||
if pictures:
|
||||
html += ' <div class="gallery">\n'
|
||||
|
||||
for picture, file_path in zip(pictures, saved_files):
|
||||
rel_path = file_path.name
|
||||
html += f""" <div class="picture-card">
|
||||
<img src="{rel_path}" alt="Picture {picture['id']}">
|
||||
<div class="picture-info">
|
||||
<h3>Picture {picture['id']}</h3>
|
||||
{f'<div class="picture-caption">{picture["caption"]}</div>' if picture['caption'] else ''}
|
||||
<div class="picture-coords">Coordinates: ({picture['x1']},{picture['y1']})-({picture['x2']},{picture['y2']})</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
html += ' </div>\n'
|
||||
else:
|
||||
html += ' <div class="no-pictures">\n <h2>No pictures found on this page</h2>\n </div>\n'
|
||||
|
||||
html += '</body>\n</html>\n'
|
||||
|
||||
with open(index_file, 'w', encoding='utf-8') as f:
|
||||
f.write(html)
|
||||
|
||||
print(f"Created index file: {index_file}")
|
||||
return index_file
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
output_dir = ensure_results_folder(args.output)
|
||||
|
||||
try:
|
||||
# Extract pictures from DocTags
|
||||
print(f"Extracting pictures from {args.doctags}...")
|
||||
pictures = extract_pictures_from_doctags(args.doctags)
|
||||
|
||||
if not pictures:
|
||||
print("No picture elements found in the DocTags file.")
|
||||
return
|
||||
|
||||
print(f"Found {len(pictures)} picture elements.")
|
||||
|
||||
# Load the image from PDF
|
||||
page_image = load_pdf_page(args.pdf, args.page, args.dpi)
|
||||
print(f"Loaded page {args.page} image: {page_image.size[0]}x{page_image.size[1]}")
|
||||
|
||||
# Adjust coordinates if needed
|
||||
if args.adjust:
|
||||
# Check if coordinates need normalization
|
||||
max_x = max([p['x2'] for p in pictures])
|
||||
max_y = max([p['y2'] for p in pictures])
|
||||
|
||||
if max_x <= DEFAULT_GRID_SIZE and max_y <= DEFAULT_GRID_SIZE:
|
||||
print(f"Detected normalized coordinates (0-{DEFAULT_GRID_SIZE} grid)")
|
||||
pictures = normalize_coordinates(pictures, page_image.width, page_image.height)
|
||||
else:
|
||||
pictures = auto_adjust_coordinates(pictures, page_image.width, page_image.height)
|
||||
|
||||
# Extract and save pictures
|
||||
saved_files = extract_and_save_pictures(
|
||||
page_image, pictures, output_dir,
|
||||
args.max_width, args.margin
|
||||
)
|
||||
|
||||
# Create HTML index
|
||||
pdf_name = Path(args.pdf).stem
|
||||
create_html_index(pictures, saved_files, pdf_name, args.page, output_dir)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,362 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
DocTags Zone Visualizer - Visualize zones identified in DocTags format.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Add parent directory to path for imports
|
||||
import sys
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from backend.utils import (ensure_results_folder, load_pdf_page, count_pdf_pages,
|
||||
normalize_coordinates, auto_adjust_coordinates)
|
||||
from backend.config import ZONE_COLORS, DEFAULT_DPI, DEFAULT_GRID_SIZE
|
||||
|
||||
# Regular expression to extract location data
|
||||
LOC_PATTERN = r'<loc_(\d+)><loc_(\d+)><loc_(\d+)><loc_(\d+)>'
|
||||
|
||||
def parse_arguments():
|
||||
"""Parse command line arguments."""
|
||||
results_dir = ensure_results_folder()
|
||||
|
||||
parser = argparse.ArgumentParser(description='Visualize zones identified in DocTags format')
|
||||
parser.add_argument('--doctags', '-d', type=str, required=False,
|
||||
help='Path to DocTags file (optional, will auto-detect if not provided)')
|
||||
parser.add_argument('--pdf', '-p', type=str, required=True,
|
||||
help='Path to original PDF file')
|
||||
parser.add_argument('--page', type=int, default=1,
|
||||
help='Page number in PDF (starts at 1)')
|
||||
parser.add_argument('--output', '-o', type=str, default=None,
|
||||
help='Output PNG file path')
|
||||
parser.add_argument('--dpi', type=int, default=DEFAULT_DPI,
|
||||
help='DPI for PDF rendering')
|
||||
parser.add_argument('--adjust', action='store_true',
|
||||
help='Try to automatically adjust scaling')
|
||||
return parser.parse_args()
|
||||
|
||||
def parse_doctags(doctags_path):
|
||||
"""Parse DocTags file and extract zones with their coordinates."""
|
||||
if not os.path.exists(doctags_path):
|
||||
raise FileNotFoundError(f"DocTags file not found: {doctags_path}")
|
||||
|
||||
with open(doctags_path, 'r', encoding='utf-8') as f:
|
||||
doctags_content = f.read()
|
||||
|
||||
# Check if file is empty or invalid
|
||||
if not doctags_content.strip():
|
||||
raise ValueError("DocTags file is empty")
|
||||
|
||||
# Extract content between <doctag> tags
|
||||
doctag_match = re.search(r'<doctag>(.*?)</doctag>', doctags_content, re.DOTALL)
|
||||
if not doctag_match:
|
||||
raise ValueError("No <doctag> tags found in the file")
|
||||
|
||||
doctag_content = doctag_match.group(1)
|
||||
zones = []
|
||||
|
||||
# Define all possible tag types to look for
|
||||
tag_types = [
|
||||
'section_header_level_1', 'section_header_level_2', 'section_header_level_3',
|
||||
'text', 'picture', 'table', 'page_header', 'page_footer',
|
||||
'title', 'author', 'abstract', 'keywords', 'paragraph',
|
||||
'list_item', 'code_block', 'footnote', 'caption'
|
||||
]
|
||||
|
||||
# Find all zones with location information using a more robust pattern
|
||||
for tag_type in tag_types:
|
||||
# Pattern to match the complete tag with location data
|
||||
pattern = rf'<{tag_type}>.*?{LOC_PATTERN}.*?</{tag_type}>'
|
||||
matches = re.finditer(pattern, doctag_content, re.DOTALL)
|
||||
|
||||
for match in matches:
|
||||
full_match = match.group(0)
|
||||
loc_match = re.search(LOC_PATTERN, full_match)
|
||||
|
||||
if loc_match:
|
||||
x1, y1, x2, y2 = map(int, loc_match.groups())
|
||||
|
||||
# Extract text content (remove location tags)
|
||||
content_start = full_match.find('>') + 1
|
||||
content_end = full_match.rfind('</')
|
||||
content = full_match[content_start:content_end]
|
||||
content = re.sub(r'<loc_\d+>', '', content).strip()
|
||||
|
||||
zones.append({
|
||||
'type': tag_type,
|
||||
'x1': x1, 'y1': y1,
|
||||
'x2': x2, 'y2': y2,
|
||||
'content': content
|
||||
})
|
||||
|
||||
# Also try a more general pattern for any tags we might have missed
|
||||
general_pattern = r'<(\w+)>.*?' + LOC_PATTERN + r'.*?</\1>'
|
||||
general_matches = re.finditer(general_pattern, doctag_content, re.DOTALL)
|
||||
|
||||
found_tags = set()
|
||||
for zone in zones:
|
||||
found_tags.add(f"{zone['type']}_{zone['x1']}_{zone['y1']}")
|
||||
|
||||
for match in general_matches:
|
||||
tag_name = match.group(1)
|
||||
if tag_name.startswith('loc_'):
|
||||
continue
|
||||
|
||||
x1, y1, x2, y2 = map(int, match.groups()[1:5])
|
||||
tag_key = f"{tag_name}_{x1}_{y1}"
|
||||
|
||||
# Avoid duplicates
|
||||
if tag_key not in found_tags:
|
||||
full_match = match.group(0)
|
||||
content_start = full_match.find('>') + 1
|
||||
content_end = full_match.rfind('</')
|
||||
content = full_match[content_start:content_end]
|
||||
content = re.sub(r'<loc_\d+>', '', content).strip()
|
||||
|
||||
zones.append({
|
||||
'type': tag_name,
|
||||
'x1': x1, 'y1': y1,
|
||||
'x2': x2, 'y2': y2,
|
||||
'content': content
|
||||
})
|
||||
found_tags.add(tag_key)
|
||||
|
||||
# If no zones found, log the content for debugging
|
||||
if not zones:
|
||||
print(f"Warning: No zones with location data found in {doctags_path}")
|
||||
print(f"DocTags content preview: {doctag_content[:500]}...")
|
||||
|
||||
# Try to find any loc_ tags to debug
|
||||
loc_tags = re.findall(r'<loc_\d+>', doctag_content)
|
||||
if loc_tags:
|
||||
print(f"Found {len(loc_tags)} location tags in the file")
|
||||
else:
|
||||
print("No location tags found in the file at all")
|
||||
|
||||
# Sort zones by position (top to bottom, left to right)
|
||||
zones.sort(key=lambda z: (z['y1'], z['x1']))
|
||||
|
||||
print(f"Parsed {len(zones)} zones from DocTags")
|
||||
for zone in zones[:5]: # Show first 5 zones for debugging
|
||||
print(f" - {zone['type']}: ({zone['x1']},{zone['y1']})-({zone['x2']},{zone['y2']})")
|
||||
|
||||
return zones
|
||||
|
||||
def create_visualization(image, zones, page_num, output_path):
|
||||
"""Create a visualization image with rectangles around zones."""
|
||||
debug_img = image.copy()
|
||||
draw = ImageDraw.Draw(debug_img, mode='RGBA') # Use RGBA mode for transparency
|
||||
|
||||
print(f"Creating visualization with {len(zones)} zones")
|
||||
|
||||
# Try to use a default font, fallback to PIL default if not available
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
|
||||
except:
|
||||
try:
|
||||
# Try macOS font locations
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 14)
|
||||
except:
|
||||
try:
|
||||
# Try Windows font locations
|
||||
font = ImageFont.truetype("C:\\Windows\\Fonts\\Arial.ttf", 14)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw rectangles for each zone
|
||||
zone_count = 0
|
||||
for zone in zones:
|
||||
zone_type = zone['type']
|
||||
color = ZONE_COLORS.get(zone_type, ZONE_COLORS['default'])
|
||||
|
||||
# Ensure coordinates are integers
|
||||
x1, y1 = int(zone['x1']), int(zone['y1'])
|
||||
x2, y2 = int(zone['x2']), int(zone['y2'])
|
||||
|
||||
# Skip invalid zones
|
||||
if x1 >= x2 or y1 >= y2:
|
||||
print(f"Skipping invalid zone {zone_type}: ({x1},{y1})-({x2},{y2})")
|
||||
continue
|
||||
|
||||
# Ensure coordinates are within image bounds
|
||||
x1 = max(0, min(x1, image.width - 1))
|
||||
y1 = max(0, min(y1, image.height - 1))
|
||||
x2 = max(0, min(x2, image.width))
|
||||
y2 = max(0, min(y2, image.height))
|
||||
|
||||
print(f"Drawing {zone_type} at ({x1},{y1})-({x2},{y2}) with color {color}")
|
||||
|
||||
# Draw rectangle with thicker line
|
||||
draw.rectangle(
|
||||
[(x1, y1), (x2, y2)],
|
||||
outline=color,
|
||||
width=3 # Increased from 2 to make more visible
|
||||
)
|
||||
|
||||
# Draw corners for better visibility
|
||||
corner_length = 10
|
||||
corner_width = 4
|
||||
# Top-left corner
|
||||
draw.line([(x1, y1), (x1 + corner_length, y1)], fill=color, width=corner_width)
|
||||
draw.line([(x1, y1), (x1, y1 + corner_length)], fill=color, width=corner_width)
|
||||
# Top-right corner
|
||||
draw.line([(x2 - corner_length, y1), (x2, y1)], fill=color, width=corner_width)
|
||||
draw.line([(x2, y1), (x2, y1 + corner_length)], fill=color, width=corner_width)
|
||||
# Bottom-left corner
|
||||
draw.line([(x1, y2 - corner_length), (x1, y2)], fill=color, width=corner_width)
|
||||
draw.line([(x1, y2), (x1 + corner_length, y2)], fill=color, width=corner_width)
|
||||
# Bottom-right corner
|
||||
draw.line([(x2 - corner_length, y2), (x2, y2)], fill=color, width=corner_width)
|
||||
draw.line([(x2, y2 - corner_length), (x2, y2)], fill=color, width=corner_width)
|
||||
|
||||
# Add zone type label with better visibility
|
||||
label_text = zone_type.replace('_', ' ').title()
|
||||
|
||||
# Get text size
|
||||
text_bbox = draw.textbbox((0, 0), label_text, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
text_height = text_bbox[3] - text_bbox[1]
|
||||
|
||||
# Position label
|
||||
label_x = min(x1 + 2, image.width - text_width - 4)
|
||||
label_y = max(y1 - text_height - 4, 2)
|
||||
|
||||
# Draw label background
|
||||
draw.rectangle(
|
||||
[(label_x - 2, label_y - 2),
|
||||
(label_x + text_width + 2, label_y + text_height + 2)],
|
||||
fill=(255, 255, 255, 200),
|
||||
outline=color,
|
||||
width=2
|
||||
)
|
||||
|
||||
# Draw label text
|
||||
draw.text(
|
||||
(label_x, label_y),
|
||||
label_text,
|
||||
fill=color,
|
||||
font=font
|
||||
)
|
||||
|
||||
zone_count += 1
|
||||
|
||||
print(f"Drew {zone_count} zones on the image")
|
||||
|
||||
# Draw page number with better visibility
|
||||
page_text = f"Page {page_num}"
|
||||
page_bbox = draw.textbbox((0, 0), page_text, font=font)
|
||||
page_width = page_bbox[2] - page_bbox[0]
|
||||
page_height = page_bbox[3] - page_bbox[1]
|
||||
|
||||
draw.rectangle(
|
||||
[(10, 10), (20 + page_width, 20 + page_height)],
|
||||
fill=(0, 0, 0, 200),
|
||||
outline=(255, 255, 255)
|
||||
)
|
||||
draw.text(
|
||||
(15, 15),
|
||||
page_text,
|
||||
fill=(255, 255, 255),
|
||||
font=font
|
||||
)
|
||||
|
||||
# Save the image
|
||||
debug_img.save(output_path, format="PNG")
|
||||
print(f"Visualization saved to: {output_path}")
|
||||
print(f"Output image size: {debug_img.size}")
|
||||
|
||||
return debug_img
|
||||
|
||||
def process_page(pdf_path, page_num, doctags_path, output_path, dpi, adjust):
|
||||
"""Process a single page of the PDF with visualization."""
|
||||
results_dir = ensure_results_folder()
|
||||
|
||||
if output_path is None:
|
||||
output_path = results_dir / f"visualization_page_{page_num}.png"
|
||||
else:
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Load the page image
|
||||
image = load_pdf_page(pdf_path, page_num, dpi)
|
||||
print(f"Page {page_num} loaded: {image.size}")
|
||||
|
||||
try:
|
||||
# Parse DocTags
|
||||
zones = parse_doctags(doctags_path)
|
||||
print(f"Found {len(zones)} zones in DocTags")
|
||||
|
||||
if zones:
|
||||
# Debug: print coordinate ranges
|
||||
x_coords = [zone['x1'] for zone in zones] + [zone['x2'] for zone in zones]
|
||||
y_coords = [zone['y1'] for zone in zones] + [zone['y2'] for zone in zones]
|
||||
print(f"Coordinate ranges: X({min(x_coords)}-{max(x_coords)}), Y({min(y_coords)}-{max(y_coords)})")
|
||||
print(f"Image dimensions: {image.width}x{image.height}")
|
||||
|
||||
# Check if we need to adjust coordinates
|
||||
max_x = max([zone['x2'] for zone in zones])
|
||||
max_y = max([zone['y2'] for zone in zones])
|
||||
|
||||
# Auto-adjust if needed
|
||||
if max_x <= DEFAULT_GRID_SIZE and max_y <= DEFAULT_GRID_SIZE:
|
||||
print(f"Detected normalized coordinates (0-{DEFAULT_GRID_SIZE} grid)")
|
||||
zones = normalize_coordinates(zones, image.width, image.height)
|
||||
print(f"After normalization - X range: {min([z['x1'] for z in zones])}-{max([z['x2'] for z in zones])}")
|
||||
elif adjust:
|
||||
print(f"Applying auto-adjustment (max coords: {max_x}, {max_y})")
|
||||
zones = auto_adjust_coordinates(zones, image.width, image.height)
|
||||
print(f"After adjustment - X range: {min([z['x1'] for z in zones])}-{max([z['x2'] for z in zones])}")
|
||||
else:
|
||||
print("No coordinate adjustment applied")
|
||||
|
||||
# Verify coordinates are within image bounds
|
||||
out_of_bounds = 0
|
||||
for zone in zones:
|
||||
if (zone['x2'] > image.width or zone['y2'] > image.height or
|
||||
zone['x1'] < 0 or zone['y1'] < 0):
|
||||
out_of_bounds += 1
|
||||
print(f"Warning: Zone {zone['type']} has out-of-bounds coordinates: "
|
||||
f"({zone['x1']},{zone['y1']})-({zone['x2']},{zone['y2']})")
|
||||
|
||||
if out_of_bounds > 0:
|
||||
print(f"Warning: {out_of_bounds} zones have coordinates outside image bounds!")
|
||||
|
||||
else:
|
||||
print(f"Warning: No zones found for page {page_num}, creating blank visualization")
|
||||
|
||||
except ValueError as e:
|
||||
print(f"Warning: {e} for page {page_num}, creating blank visualization")
|
||||
zones = []
|
||||
|
||||
# Create visualization (even if no zones)
|
||||
create_visualization(image, zones, page_num, output_path)
|
||||
|
||||
return True
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
|
||||
# Check if files exist
|
||||
if not os.path.exists(args.pdf):
|
||||
print(f"Error: PDF file not found: {args.pdf}")
|
||||
return
|
||||
|
||||
if not os.path.exists(args.doctags):
|
||||
print(f"Error: DocTags file not found: {args.doctags}")
|
||||
return
|
||||
|
||||
# Process the page
|
||||
process_page(
|
||||
args.pdf,
|
||||
args.page,
|
||||
args.doctags,
|
||||
args.output,
|
||||
args.dpi,
|
||||
args.adjust
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
66
backend/pom.xml
Normal file
66
backend/pom.xml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.3.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.docling</groupId>
|
||||
<artifactId>docling-studio</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<name>Docling Studio Backend</name>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.liquibase</groupId>
|
||||
<artifactId>liquibase-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
transformers
|
||||
accelerate
|
||||
torch
|
||||
torchvision
|
||||
pdf2image
|
||||
pillow
|
||||
requests
|
||||
flask
|
||||
docling_core
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.docling.studio;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class DoclingStudioApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DoclingStudioApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.docling.studio.analysis;
|
||||
|
||||
import com.docling.studio.analysis.dto.AnalysisResponse;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/analyses")
|
||||
public class AnalysisController {
|
||||
|
||||
private final AnalysisService service;
|
||||
|
||||
public AnalysisController(AnalysisService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public AnalysisResponse create(@RequestBody Map<String, String> body) {
|
||||
UUID documentId = UUID.fromString(body.get("documentId"));
|
||||
AnalysisJob job = service.create(documentId);
|
||||
return AnalysisResponse.from(job);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<AnalysisResponse> list() {
|
||||
return service.findAll().stream().map(AnalysisResponse::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public AnalysisResponse get(@PathVariable UUID id) {
|
||||
return AnalysisResponse.from(service.findById(id));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
||||
service.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.docling.studio.analysis;
|
||||
|
||||
import com.docling.studio.document.Document;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "analysis_jobs")
|
||||
public class AnalysisJob {
|
||||
|
||||
public enum Status { PENDING, RUNNING, COMPLETED, FAILED }
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "document_id", nullable = false)
|
||||
private Document document;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private Status status;
|
||||
|
||||
@Column(columnDefinition = "text")
|
||||
private String contentMarkdown;
|
||||
|
||||
@Column(columnDefinition = "text")
|
||||
private String contentHtml;
|
||||
|
||||
@Column(columnDefinition = "text")
|
||||
private String pagesJson;
|
||||
|
||||
private String errorMessage;
|
||||
|
||||
private Instant startedAt;
|
||||
private Instant completedAt;
|
||||
private Instant createdAt;
|
||||
|
||||
protected AnalysisJob() {}
|
||||
|
||||
public AnalysisJob(Document document) {
|
||||
this.id = UUID.randomUUID();
|
||||
this.document = document;
|
||||
this.status = Status.PENDING;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public void markRunning() {
|
||||
this.status = Status.RUNNING;
|
||||
this.startedAt = Instant.now();
|
||||
}
|
||||
|
||||
public void markCompleted(String markdown, String html, String pagesJson) {
|
||||
this.status = Status.COMPLETED;
|
||||
this.contentMarkdown = markdown;
|
||||
this.contentHtml = html;
|
||||
this.pagesJson = pagesJson;
|
||||
this.completedAt = Instant.now();
|
||||
}
|
||||
|
||||
public void markFailed(String error) {
|
||||
this.status = Status.FAILED;
|
||||
this.errorMessage = error;
|
||||
this.completedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Document getDocument() { return document; }
|
||||
public Status getStatus() { return status; }
|
||||
public String getContentMarkdown() { return contentMarkdown; }
|
||||
public String getContentHtml() { return contentHtml; }
|
||||
public String getPagesJson() { return pagesJson; }
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public Instant getStartedAt() { return startedAt; }
|
||||
public Instant getCompletedAt() { return completedAt; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.docling.studio.analysis;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AnalysisJobRepository extends JpaRepository<AnalysisJob, UUID> {
|
||||
List<AnalysisJob> findAllByOrderByCreatedAtDesc();
|
||||
List<AnalysisJob> findByDocumentIdOrderByCreatedAtDesc(UUID documentId);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.docling.studio.analysis;
|
||||
|
||||
import com.docling.studio.document.Document;
|
||||
import com.docling.studio.document.DocumentParserClient;
|
||||
import com.docling.studio.document.DocumentService;
|
||||
import com.docling.studio.shared.exception.ResourceNotFoundException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AnalysisService {
|
||||
|
||||
private final AnalysisJobRepository repository;
|
||||
private final DocumentService documentService;
|
||||
private final DocumentParserClient parserClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AnalysisService(
|
||||
AnalysisJobRepository repository,
|
||||
DocumentService documentService,
|
||||
DocumentParserClient parserClient,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.repository = repository;
|
||||
this.documentService = documentService;
|
||||
this.parserClient = parserClient;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public AnalysisJob create(UUID documentId) {
|
||||
Document doc = documentService.findById(documentId);
|
||||
AnalysisJob job = new AnalysisJob(doc);
|
||||
repository.save(job);
|
||||
runAnalysis(job.getId());
|
||||
return job;
|
||||
}
|
||||
|
||||
@Async("analysisExecutor")
|
||||
public void runAnalysis(UUID jobId) {
|
||||
AnalysisJob job = repository.findById(jobId).orElseThrow();
|
||||
job.markRunning();
|
||||
repository.save(job);
|
||||
|
||||
try {
|
||||
Path filePath = documentService.getFilePath(job.getDocument().getId());
|
||||
Map<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());
|
||||
}
|
||||
|
||||
job.markCompleted(markdown, html, pagesJson);
|
||||
repository.save(job);
|
||||
|
||||
} catch (Exception e) {
|
||||
job.markFailed(e.getMessage());
|
||||
repository.save(job);
|
||||
}
|
||||
}
|
||||
|
||||
public AnalysisJob findById(UUID id) {
|
||||
return repository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Analysis not found: " + id));
|
||||
}
|
||||
|
||||
public List<AnalysisJob> findAll() {
|
||||
return repository.findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
|
||||
public void delete(UUID id) {
|
||||
AnalysisJob job = findById(id);
|
||||
repository.delete(job);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.docling.studio.analysis.dto;
|
||||
|
||||
import com.docling.studio.analysis.AnalysisJob;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AnalysisResponse(
|
||||
UUID id,
|
||||
UUID documentId,
|
||||
String documentFilename,
|
||||
String status,
|
||||
String contentMarkdown,
|
||||
String contentHtml,
|
||||
String pagesJson,
|
||||
String errorMessage,
|
||||
Instant startedAt,
|
||||
Instant completedAt,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static AnalysisResponse from(AnalysisJob job) {
|
||||
return new AnalysisResponse(
|
||||
job.getId(),
|
||||
job.getDocument().getId(),
|
||||
job.getDocument().getFilename(),
|
||||
job.getStatus().name(),
|
||||
job.getContentMarkdown(),
|
||||
job.getContentHtml(),
|
||||
job.getPagesJson(),
|
||||
job.getErrorMessage(),
|
||||
job.getStartedAt(),
|
||||
job.getCompletedAt(),
|
||||
job.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.docling.studio.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
@Bean(name = "analysisExecutor")
|
||||
public Executor analysisExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2);
|
||||
executor.setMaxPoolSize(4);
|
||||
executor.setQueueCapacity(20);
|
||||
executor.setThreadNamePrefix("analysis-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.docling.studio.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.document-parser")
|
||||
public class DocumentParserProperties {
|
||||
|
||||
private String baseUrl = "http://localhost:8000";
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.docling.studio.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins("http://localhost:3000", "http://localhost:5173")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.docling.studio.document;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "documents")
|
||||
public class Document {
|
||||
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String filename;
|
||||
|
||||
private String contentType;
|
||||
|
||||
private Long fileSize;
|
||||
|
||||
private Integer pageCount;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String storagePath;
|
||||
|
||||
private Instant createdAt;
|
||||
|
||||
protected Document() {}
|
||||
|
||||
public Document(String filename, String contentType, Long fileSize, String storagePath) {
|
||||
this.id = UUID.randomUUID();
|
||||
this.filename = filename;
|
||||
this.contentType = contentType;
|
||||
this.fileSize = fileSize;
|
||||
this.storagePath = storagePath;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public String getFilename() { return filename; }
|
||||
public String getContentType() { return contentType; }
|
||||
public Long getFileSize() { return fileSize; }
|
||||
public Integer getPageCount() { return pageCount; }
|
||||
public void setPageCount(Integer pageCount) { this.pageCount = pageCount; }
|
||||
public String getStoragePath() { return storagePath; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.docling.studio.document;
|
||||
|
||||
import com.docling.studio.document.dto.DocumentResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/documents")
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService service;
|
||||
|
||||
public DocumentController(DocumentService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
public DocumentResponse upload(@RequestParam("file") MultipartFile file) {
|
||||
Document doc = service.upload(file);
|
||||
return DocumentResponse.from(doc);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<DocumentResponse> list() {
|
||||
return service.findAll().stream().map(DocumentResponse::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public DocumentResponse get(@PathVariable UUID id) {
|
||||
return DocumentResponse.from(service.findById(id));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
||||
service.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/preview")
|
||||
public ResponseEntity<byte[]> preview(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "150") int dpi
|
||||
) {
|
||||
byte[] bytes = service.getPreview(id, page, dpi);
|
||||
if (bytes == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok().contentType(MediaType.IMAGE_PNG).body(bytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.docling.studio.document;
|
||||
|
||||
import com.docling.studio.config.DocumentParserProperties;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class DocumentParserClient {
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
public DocumentParserClient(DocumentParserProperties props) {
|
||||
this.webClient = WebClient.builder()
|
||||
.baseUrl(props.getBaseUrl())
|
||||
.codecs(config -> config.defaultCodecs().maxInMemorySize(50 * 1024 * 1024))
|
||||
.build();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> parse(Path filePath, String filename) {
|
||||
MultipartBodyBuilder builder = new MultipartBodyBuilder();
|
||||
builder.part("file", new FileSystemResource(filePath))
|
||||
.filename(filename)
|
||||
.contentType(MediaType.APPLICATION_PDF);
|
||||
|
||||
return webClient.post()
|
||||
.uri("/parse")
|
||||
.body(BodyInserters.fromMultipartData(builder.build()))
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.block();
|
||||
}
|
||||
|
||||
public byte[] preview(Path filePath, String filename, int page, int dpi) {
|
||||
MultipartBodyBuilder builder = new MultipartBodyBuilder();
|
||||
builder.part("file", new FileSystemResource(filePath))
|
||||
.filename(filename)
|
||||
.contentType(MediaType.APPLICATION_PDF);
|
||||
|
||||
try {
|
||||
return webClient.post()
|
||||
.uri(uri -> uri.path("/preview")
|
||||
.queryParam("page", page)
|
||||
.queryParam("dpi", dpi)
|
||||
.build())
|
||||
.body(BodyInserters.fromMultipartData(builder.build()))
|
||||
.retrieve()
|
||||
.bodyToMono(byte[].class)
|
||||
.block();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.docling.studio.document;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface DocumentRepository extends JpaRepository<Document, UUID> {
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.docling.studio.document;
|
||||
|
||||
import com.docling.studio.shared.exception.ResourceNotFoundException;
|
||||
import com.docling.studio.shared.exception.ServiceException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class DocumentService {
|
||||
|
||||
private final DocumentRepository repository;
|
||||
private final DocumentParserClient parserClient;
|
||||
private final Path storagePath;
|
||||
|
||||
public DocumentService(
|
||||
DocumentRepository repository,
|
||||
DocumentParserClient parserClient,
|
||||
@Value("${app.storage.path:./uploads}") String storagePath
|
||||
) {
|
||||
this.repository = repository;
|
||||
this.parserClient = parserClient;
|
||||
this.storagePath = Path.of(storagePath);
|
||||
}
|
||||
|
||||
public Document upload(MultipartFile file) {
|
||||
if (file.isEmpty() || file.getOriginalFilename() == null) {
|
||||
throw new IllegalArgumentException("File is empty or has no name");
|
||||
}
|
||||
|
||||
try {
|
||||
Files.createDirectories(storagePath);
|
||||
String safeName = UUID.randomUUID() + "_" + file.getOriginalFilename();
|
||||
Path target = storagePath.resolve(safeName);
|
||||
file.transferTo(target);
|
||||
|
||||
Document doc = new Document(
|
||||
file.getOriginalFilename(),
|
||||
file.getContentType(),
|
||||
file.getSize(),
|
||||
target.toString()
|
||||
);
|
||||
return repository.save(doc);
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new ServiceException("Failed to store file", e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<Document> findAll() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
public Document findById(UUID id) {
|
||||
return repository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Document not found: " + id));
|
||||
}
|
||||
|
||||
public void delete(UUID id) {
|
||||
Document doc = findById(id);
|
||||
try {
|
||||
Files.deleteIfExists(Path.of(doc.getStoragePath()));
|
||||
} catch (IOException e) {
|
||||
// Log but continue
|
||||
}
|
||||
repository.delete(doc);
|
||||
}
|
||||
|
||||
public byte[] getPreview(UUID id, int page, int dpi) {
|
||||
Document doc = findById(id);
|
||||
return parserClient.preview(Path.of(doc.getStoragePath()), doc.getFilename(), page, dpi);
|
||||
}
|
||||
|
||||
public Path getFilePath(UUID id) {
|
||||
Document doc = findById(id);
|
||||
return Path.of(doc.getStoragePath());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.docling.studio.document.dto;
|
||||
|
||||
import com.docling.studio.document.Document;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record DocumentResponse(
|
||||
UUID id,
|
||||
String filename,
|
||||
String contentType,
|
||||
Long fileSize,
|
||||
Integer pageCount,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static DocumentResponse from(Document doc) {
|
||||
return new DocumentResponse(
|
||||
doc.getId(), doc.getFilename(), doc.getContentType(),
|
||||
doc.getFileSize(), doc.getPageCount(), doc.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.docling.studio.shared.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResourceNotFoundException.class)
|
||||
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ServiceException.class)
|
||||
public ProblemDetail handleService(ServiceException ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.docling.studio.shared.exception;
|
||||
|
||||
public class ResourceNotFoundException extends RuntimeException {
|
||||
public ResourceNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.docling.studio.shared.exception;
|
||||
|
||||
public class ServiceException extends RuntimeException {
|
||||
public ServiceException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ServiceException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
24
backend/src/main/resources/application.yml
Normal file
24
backend/src/main/resources/application.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
server:
|
||||
port: 8081
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/docling_studio
|
||||
username: app
|
||||
password: app
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
open-in-view: false
|
||||
liquibase:
|
||||
change-log: classpath:db/changelog/db.changelog-master.yml
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
|
||||
app:
|
||||
document-parser:
|
||||
base-url: http://localhost:8000
|
||||
storage:
|
||||
path: ./uploads
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 001-create-documents
|
||||
author: docling-studio
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: documents
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: uuid
|
||||
constraints:
|
||||
primaryKey: true
|
||||
- column:
|
||||
name: filename
|
||||
type: varchar(255)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: content_type
|
||||
type: varchar(100)
|
||||
- column:
|
||||
name: file_size
|
||||
type: bigint
|
||||
- column:
|
||||
name: page_count
|
||||
type: int
|
||||
- column:
|
||||
name: storage_path
|
||||
type: varchar(500)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: created_at
|
||||
type: timestamp
|
||||
defaultValueComputed: NOW()
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 002-create-analysis-jobs
|
||||
author: docling-studio
|
||||
changes:
|
||||
- createTable:
|
||||
tableName: analysis_jobs
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: uuid
|
||||
constraints:
|
||||
primaryKey: true
|
||||
- column:
|
||||
name: document_id
|
||||
type: uuid
|
||||
constraints:
|
||||
nullable: false
|
||||
foreignKeyName: fk_analysis_document
|
||||
references: documents(id)
|
||||
- column:
|
||||
name: status
|
||||
type: varchar(20)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: content_markdown
|
||||
type: text
|
||||
- column:
|
||||
name: content_html
|
||||
type: text
|
||||
- column:
|
||||
name: pages_json
|
||||
type: text
|
||||
- column:
|
||||
name: error_message
|
||||
type: text
|
||||
- column:
|
||||
name: started_at
|
||||
type: timestamp
|
||||
- column:
|
||||
name: completed_at
|
||||
type: timestamp
|
||||
- column:
|
||||
name: created_at
|
||||
type: timestamp
|
||||
defaultValueComputed: NOW()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
databaseChangeLog:
|
||||
- include:
|
||||
file: db/changelog/001-create-documents.yml
|
||||
- include:
|
||||
file: db/changelog/002-create-analysis-jobs.yml
|
||||
214
backend/utils.py
214
backend/utils.py
|
|
@ -1,214 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common utilities for DocTags processing
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Dict, List
|
||||
import pdf2image
|
||||
from pdf2image.pdf2image import pdfinfo_from_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration constants
|
||||
DEFAULT_DPI = 200
|
||||
DEFAULT_GRID_SIZE = 500
|
||||
MAX_WIDTH = 1200
|
||||
RESULTS_DIR_NAME = "results"
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project root directory."""
|
||||
# If running from backend/page_treatment/, go up to root
|
||||
current_file = Path(__file__)
|
||||
if current_file.parent.name == 'page_treatment':
|
||||
return current_file.parent.parent.parent
|
||||
elif current_file.parent.name == 'backend':
|
||||
return current_file.parent.parent
|
||||
else:
|
||||
return Path.cwd()
|
||||
|
||||
def ensure_results_folder(custom_path: Optional[str] = None) -> Path:
|
||||
"""Create and return the results folder path."""
|
||||
if custom_path:
|
||||
results_dir = Path(custom_path)
|
||||
else:
|
||||
results_dir = get_project_root() / RESULTS_DIR_NAME
|
||||
|
||||
if not results_dir.exists():
|
||||
results_dir.mkdir(parents=True)
|
||||
logger.info(f"Created results directory: {results_dir}")
|
||||
|
||||
return results_dir
|
||||
|
||||
def count_pdf_pages(pdf_path: str) -> int:
|
||||
"""Count the number of pages in a PDF file."""
|
||||
if not os.path.exists(pdf_path):
|
||||
logger.error(f"PDF file not found: {pdf_path}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
info = pdfinfo_from_path(pdf_path)
|
||||
return info["Pages"]
|
||||
except Exception as e:
|
||||
logger.warning(f"pdfinfo failed: {e}, trying fallback method")
|
||||
try:
|
||||
# Fallback: convert first page to check
|
||||
images = pdf2image.convert_from_path(pdf_path, dpi=72, first_page=1, last_page=1)
|
||||
if not images:
|
||||
return 0
|
||||
|
||||
# Binary search for last page
|
||||
low, high = 1, 1000
|
||||
while low < high:
|
||||
mid = (low + high + 1) // 2
|
||||
try:
|
||||
images = pdf2image.convert_from_path(pdf_path, dpi=72, first_page=mid, last_page=mid)
|
||||
if images:
|
||||
low = mid
|
||||
else:
|
||||
high = mid - 1
|
||||
except:
|
||||
high = mid - 1
|
||||
|
||||
return low
|
||||
except Exception as e2:
|
||||
logger.error(f"Error counting PDF pages: {e2}")
|
||||
return 0
|
||||
|
||||
def load_pdf_page(pdf_path: str, page_num: int = 1, dpi: int = DEFAULT_DPI) -> Optional[object]:
|
||||
"""Load a specific page from PDF as an image."""
|
||||
if not os.path.exists(pdf_path):
|
||||
raise FileNotFoundError(f"PDF file not found: {pdf_path}")
|
||||
|
||||
logger.info(f"Converting PDF page {page_num} to image (DPI: {dpi})...")
|
||||
try:
|
||||
pdf_images = pdf2image.convert_from_path(
|
||||
pdf_path,
|
||||
dpi=dpi,
|
||||
first_page=page_num,
|
||||
last_page=page_num
|
||||
)
|
||||
if not pdf_images:
|
||||
raise Exception(f"Could not extract page {page_num} from PDF")
|
||||
return pdf_images[0]
|
||||
except Exception as e:
|
||||
raise Exception(f"Error converting PDF to image: {e}")
|
||||
|
||||
def normalize_coordinates(elements: List[Dict], image_width: int, image_height: int,
|
||||
grid_size: int = DEFAULT_GRID_SIZE) -> List[Dict]:
|
||||
"""
|
||||
Normalize coordinates from DocTags grid to actual image dimensions.
|
||||
|
||||
Args:
|
||||
elements: List of elements with x1, y1, x2, y2 coordinates
|
||||
image_width: Width of the image in pixels
|
||||
image_height: Height of the image in pixels
|
||||
grid_size: The grid size used in DocTags (default 500)
|
||||
|
||||
Returns:
|
||||
List of elements with normalized coordinates
|
||||
"""
|
||||
normalized = []
|
||||
for element in elements:
|
||||
new_element = element.copy()
|
||||
new_element['x1'] = int(element['x1'] * image_width / grid_size)
|
||||
new_element['y1'] = int(element['y1'] * image_height / grid_size)
|
||||
new_element['x2'] = int(element['x2'] * image_width / grid_size)
|
||||
new_element['y2'] = int(element['y2'] * image_height / grid_size)
|
||||
normalized.append(new_element)
|
||||
return normalized
|
||||
|
||||
def auto_adjust_coordinates(elements: List[Dict], image_width: int, image_height: int) -> List[Dict]:
|
||||
"""
|
||||
Automatically adjust coordinates based on image dimensions.
|
||||
"""
|
||||
if not elements:
|
||||
return elements
|
||||
|
||||
# Find maximum coordinates
|
||||
max_x = max([el['x2'] for el in elements])
|
||||
max_y = max([el['y2'] for el in elements])
|
||||
|
||||
# Check if coordinates are in normalized grid (0-500 range)
|
||||
if max_x <= DEFAULT_GRID_SIZE and max_y <= DEFAULT_GRID_SIZE:
|
||||
logger.info(f"Detected normalized coordinates (0-{DEFAULT_GRID_SIZE} grid)")
|
||||
return normalize_coordinates(elements, image_width, image_height)
|
||||
|
||||
# Calculate scaling factors
|
||||
x_scale = calculate_scale_factor(max_x, image_width)
|
||||
y_scale = calculate_scale_factor(max_y, image_height)
|
||||
|
||||
# Apply scaling
|
||||
adjusted = []
|
||||
for el in elements:
|
||||
adjusted_el = el.copy()
|
||||
adjusted_el['x1'] = int(el['x1'] * x_scale)
|
||||
adjusted_el['y1'] = int(el['y1'] * y_scale)
|
||||
adjusted_el['x2'] = int(el['x2'] * x_scale)
|
||||
adjusted_el['y2'] = int(el['y2'] * y_scale)
|
||||
adjusted.append(adjusted_el)
|
||||
|
||||
logger.info(f"Applied auto-scaling: X={x_scale:.3f}, Y={y_scale:.3f}")
|
||||
return adjusted
|
||||
|
||||
def calculate_scale_factor(max_coord: float, image_size: float) -> float:
|
||||
"""Calculate appropriate scaling factor."""
|
||||
if max_coord <= 0:
|
||||
return 1.0
|
||||
|
||||
# If coordinates are way off, apply aggressive scaling
|
||||
if max_coord > image_size * 5 or max_coord < image_size / 5:
|
||||
return image_size / max_coord
|
||||
|
||||
# Otherwise, apply conservative scaling
|
||||
if max_coord > image_size:
|
||||
return min(image_size / max_coord, 1.0)
|
||||
else:
|
||||
return max(image_size / max_coord, 0.5)
|
||||
|
||||
# In backend/utils.py, make sure this function exists:
|
||||
def run_command_with_timeout(command: str, timeout: int = 300, input_text: str = "n\n") -> Tuple[bool, str, str]:
|
||||
"""
|
||||
Run a command with timeout and return success, stdout, stderr.
|
||||
"""
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
stdout, stderr = process.communicate(input=input_text, timeout=timeout)
|
||||
success = process.returncode == 0
|
||||
|
||||
return success, stdout, stderr
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
return False, "", "Command timed out"
|
||||
except Exception as e:
|
||||
return False, "", str(e)
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""Format duration in seconds to human readable format."""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
else:
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
def validate_coordinates(x1: int, y1: int, x2: int, y2: int,
|
||||
width: int, height: int) -> bool:
|
||||
"""Validate that coordinates are within bounds."""
|
||||
return (0 <= x1 < x2 <= width and
|
||||
0 <= y1 < y2 <= height)
|
||||
19
docker-compose.dev.yml
Normal file
19
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: app
|
||||
POSTGRES_DB: docling_studio
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U app -d docling_studio"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
@ -1,7 +1,47 @@
|
|||
services:
|
||||
analyser:
|
||||
build: .
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: app
|
||||
POSTGRES_DB: docling_studio
|
||||
ports:
|
||||
- "8080:5000"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U app -d docling_studio"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
document-parser:
|
||||
build:
|
||||
context: ./document-parser
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
ports:
|
||||
- "8081:8081"
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/docling_studio
|
||||
SPRING_DATASOURCE_USERNAME: app
|
||||
SPRING_DATASOURCE_PASSWORD: app
|
||||
APP_DOCUMENT-PARSER_BASE-URL: http://document-parser:8000
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
|
|||
Binary file not shown.
16
document-parser/Dockerfile
Normal file
16
document-parser/Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
poppler-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
236
document-parser/main.py
Normal file
236
document-parser/main.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from docling.document_converter import DocumentConverter
|
||||
from pdf2image import convert_from_bytes
|
||||
from PIL import Image
|
||||
|
||||
app = FastAPI(title="Docling Studio - Document Parser")
|
||||
|
||||
converter = DocumentConverter()
|
||||
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
|
||||
# --- Response models ---
|
||||
|
||||
class PageElement(BaseModel):
|
||||
type: str
|
||||
bbox: list[float]
|
||||
content: str
|
||||
|
||||
|
||||
class PageDetail(BaseModel):
|
||||
page_number: int
|
||||
width: float
|
||||
height: float
|
||||
elements: list[PageElement]
|
||||
|
||||
|
||||
class ParseResponse(BaseModel):
|
||||
filename: str
|
||||
page_count: int
|
||||
content_markdown: str
|
||||
content_html: str
|
||||
pages: list[PageDetail]
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def extract_pages_detail(doc_result) -> list[PageDetail]:
|
||||
"""Extract per-page element details with bounding boxes from Docling result."""
|
||||
pages: dict[int, PageDetail] = {}
|
||||
document = doc_result.document
|
||||
|
||||
# Get page dimensions from document pages
|
||||
if hasattr(document, 'pages') and document.pages:
|
||||
for page_key, page_obj in document.pages.items():
|
||||
page_no = int(page_key) if isinstance(page_key, str) else page_key
|
||||
width = page_obj.size.width if hasattr(page_obj, 'size') and page_obj.size else 612.0
|
||||
height = page_obj.size.height if hasattr(page_obj, 'size') and page_obj.size else 792.0
|
||||
pages[page_no] = PageDetail(
|
||||
page_number=page_no,
|
||||
width=width,
|
||||
height=height,
|
||||
elements=[]
|
||||
)
|
||||
|
||||
# Iterate document items to extract elements with bounding boxes
|
||||
if hasattr(document, 'body') and hasattr(document.body, 'children'):
|
||||
_extract_elements_recursive(document, document.body.children, pages)
|
||||
|
||||
# Fallback: if we have content items
|
||||
if hasattr(document, 'texts'):
|
||||
for text_item in document.texts:
|
||||
_process_content_item(text_item, pages)
|
||||
|
||||
# Sort by page number
|
||||
return sorted(pages.values(), key=lambda p: p.page_number)
|
||||
|
||||
|
||||
def _process_content_item(item, pages: dict[int, PageDetail]):
|
||||
"""Process a single content item and add it to the appropriate page."""
|
||||
if not hasattr(item, 'prov') or not item.prov:
|
||||
return
|
||||
|
||||
for prov in item.prov:
|
||||
page_no = prov.page_no if hasattr(prov, 'page_no') else 1
|
||||
|
||||
if page_no not in pages:
|
||||
pages[page_no] = PageDetail(
|
||||
page_number=page_no, width=612.0, height=792.0, elements=[]
|
||||
)
|
||||
|
||||
bbox = [0, 0, 0, 0]
|
||||
if hasattr(prov, 'bbox') and prov.bbox:
|
||||
b = prov.bbox
|
||||
if hasattr(b, 'l'):
|
||||
bbox = [b.l, b.t, b.r, b.b]
|
||||
elif isinstance(b, (list, tuple)) and len(b) >= 4:
|
||||
bbox = list(b[:4])
|
||||
|
||||
element_type = _get_element_type(item)
|
||||
content = ""
|
||||
if hasattr(item, 'text'):
|
||||
content = item.text or ""
|
||||
elif hasattr(item, 'export_to_markdown'):
|
||||
content = item.export_to_markdown()
|
||||
|
||||
pages[page_no].elements.append(PageElement(
|
||||
type=element_type,
|
||||
bbox=bbox,
|
||||
content=content[:500] # Truncate long content
|
||||
))
|
||||
|
||||
|
||||
def _extract_elements_recursive(document, children, pages: dict[int, PageDetail]):
|
||||
"""Recursively extract elements from document tree."""
|
||||
if not children:
|
||||
return
|
||||
for child_ref in children:
|
||||
# Resolve reference if needed
|
||||
item = child_ref
|
||||
if hasattr(child_ref, '__ref__'):
|
||||
item = document.resolve_ref(child_ref)
|
||||
if item:
|
||||
_process_content_item(item, pages)
|
||||
if hasattr(item, 'children') and item.children:
|
||||
_extract_elements_recursive(document, item.children, pages)
|
||||
|
||||
|
||||
def _get_element_type(item) -> str:
|
||||
"""Determine the element type from a Docling document item."""
|
||||
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)
|
||||
async def parse(file: UploadFile):
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No filename provided")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File too large (max 50MB)")
|
||||
|
||||
suffix = Path(file.filename).suffix
|
||||
tmp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
result = converter.convert(tmp_path)
|
||||
doc = result.document
|
||||
|
||||
content_markdown = doc.export_to_markdown()
|
||||
content_html = doc.export_to_html() if hasattr(doc, 'export_to_html') else ""
|
||||
|
||||
page_count = 0
|
||||
if hasattr(doc, 'pages') and doc.pages:
|
||||
page_count = len(doc.pages)
|
||||
|
||||
pages_detail = extract_pages_detail(result)
|
||||
if not pages_detail and page_count > 0:
|
||||
pages_detail = [
|
||||
PageDetail(page_number=i + 1, width=612.0, height=792.0, elements=[])
|
||||
for i in range(page_count)
|
||||
]
|
||||
|
||||
return ParseResponse(
|
||||
filename=file.filename,
|
||||
page_count=page_count or len(pages_detail) or 1,
|
||||
content_markdown=content_markdown,
|
||||
content_html=content_html,
|
||||
pages=pages_detail,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=422, detail=f"Failed to parse document: {str(e)}")
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
@app.post("/preview")
|
||||
async def preview(
|
||||
file: UploadFile,
|
||||
page: int = Query(1, ge=1),
|
||||
dpi: int = Query(150, ge=72, le=300),
|
||||
):
|
||||
"""Generate a PNG preview of a specific page."""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No filename provided")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File too large (max 50MB)")
|
||||
|
||||
try:
|
||||
images = convert_from_bytes(content, first_page=page, last_page=page, dpi=dpi)
|
||||
if not images:
|
||||
raise HTTPException(status_code=404, detail=f"Page {page} not found")
|
||||
|
||||
buf = io.BytesIO()
|
||||
images[0].save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
return StreamingResponse(buf, media_type="image/png")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=422, detail=f"Failed to generate preview: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
6
document-parser/requirements.txt
Normal file
6
document-parser/requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
docling
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
python-multipart
|
||||
pdf2image
|
||||
pillow
|
||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocTags Batch Processor</title>
|
||||
<link rel="stylesheet" href="static/styles.css">
|
||||
<link rel="stylesheet" href="static/batch-styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>DocTags Batch Processor</h1>
|
||||
|
||||
<!-- Back to Single Page Processor -->
|
||||
<div class="nav-link">
|
||||
<a href="/">← Back to Single Page Processor</a>
|
||||
</div>
|
||||
|
||||
<!-- Settings Panel -->
|
||||
<div class="settings-panel">
|
||||
<h3>Batch Processing Configuration</h3>
|
||||
|
||||
<!-- PDF Selection -->
|
||||
<div class="batch-settings-grid">
|
||||
<div class="form-group">
|
||||
<label for="batch_pdf_file">PDF Document</label>
|
||||
<select id="batch_pdf_file" name="batch_pdf_file">
|
||||
<option value="">Select a PDF file...</option>
|
||||
</select>
|
||||
<div id="batch-pdf-info" class="pdf-info hidden">
|
||||
<span class="info-label">Total pages:</span>
|
||||
<span id="total-pages">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Range Selection -->
|
||||
<div class="form-group">
|
||||
<label>Page Range</label>
|
||||
<div class="page-range-controls">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="page_range" value="all" checked>
|
||||
All pages
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="page_range" value="range">
|
||||
Custom range
|
||||
</label>
|
||||
</div>
|
||||
<div id="custom-range" class="custom-range hidden">
|
||||
<input type="number" id="start_page" min="1" placeholder="Start">
|
||||
<span>to</span>
|
||||
<input type="number" id="end_page" min="1" placeholder="End">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Options -->
|
||||
<div class="form-group">
|
||||
<label>Processing Options</label>
|
||||
<div class="processing-options">
|
||||
<label>
|
||||
<input type="checkbox" id="batch_adjust" checked>
|
||||
Auto-adjust coordinates
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="parallel_processing" checked>
|
||||
Parallel processing
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" id="generate_report" checked>
|
||||
Generate summary report
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Progress Overview -->
|
||||
<div class="batch-progress-panel hidden" id="batch-progress-panel">
|
||||
<h3>Processing Progress</h3>
|
||||
|
||||
<!-- Overall Progress -->
|
||||
<div class="overall-progress">
|
||||
<div class="progress-header">
|
||||
<span>Overall Progress</span>
|
||||
<span id="overall-percentage">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="overall-progress-fill"></div>
|
||||
</div>
|
||||
<div class="progress-stats">
|
||||
<span id="pages-completed">0</span> / <span id="pages-total">0</span> pages completed
|
||||
<span class="separator">•</span>
|
||||
<span>Elapsed: <span id="elapsed-time">00:00</span></span>
|
||||
<span class="separator">•</span>
|
||||
<span>ETA: <span id="eta-time">--:--</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage Progress -->
|
||||
<div class="stage-progress">
|
||||
<div class="stage-item">
|
||||
<div class="stage-header">
|
||||
<span class="stage-icon">📄</span>
|
||||
<span class="stage-name">Analysis</span>
|
||||
<span class="stage-percentage" id="analysis-percentage">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar small">
|
||||
<div class="progress-fill analysis" id="analysis-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-item">
|
||||
<div class="stage-header">
|
||||
<span class="stage-icon">🎨</span>
|
||||
<span class="stage-name">Visualization</span>
|
||||
<span class="stage-percentage" id="visualization-percentage">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar small">
|
||||
<div class="progress-fill visualization" id="visualization-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-item">
|
||||
<div class="stage-header">
|
||||
<span class="stage-icon">🖼️</span>
|
||||
<span class="stage-name">Extraction</span>
|
||||
<span class="stage-percentage" id="extraction-percentage">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar small">
|
||||
<div class="progress-fill extraction" id="extraction-progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="batch-controls">
|
||||
<button id="start-batch-btn" class="primary-btn" onclick="startBatchProcessing()">
|
||||
Start Batch Processing
|
||||
</button>
|
||||
<button id="pause-batch-btn" class="secondary-btn hidden" onclick="pauseBatchProcessing()">
|
||||
Pause
|
||||
</button>
|
||||
<button id="resume-batch-btn" class="secondary-btn hidden" onclick="resumeBatchProcessing()">
|
||||
Resume
|
||||
</button>
|
||||
<button id="cancel-batch-btn" class="danger-btn hidden" onclick="cancelBatchProcessing()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Page Status Grid -->
|
||||
<div class="page-status-grid hidden" id="page-status-grid">
|
||||
<h3>Page Processing Status</h3>
|
||||
<div class="page-grid" id="page-grid">
|
||||
<!-- Page status items will be dynamically added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div class="batch-results hidden" id="batch-results">
|
||||
<h3>Batch Processing Results</h3>
|
||||
|
||||
<!-- Summary Statistics -->
|
||||
<div class="results-summary">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-success">0</div>
|
||||
<div class="stat-label">Successful</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-failed">0</div>
|
||||
<div class="stat-label">Failed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-duration">00:00</div>
|
||||
<div class="stat-label">Total Time</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="stat-images">0</div>
|
||||
<div class="stat-label">Images Extracted</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="results-actions">
|
||||
<button class="action-btn" onclick="downloadResults()">
|
||||
📥 Download All Results
|
||||
</button>
|
||||
<button class="action-btn" onclick="viewReport()">
|
||||
📊 View Detailed Report
|
||||
</button>
|
||||
<button class="action-btn" onclick="openResultsFolder()">
|
||||
📁 Open Results Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Failed Pages (if any) -->
|
||||
<div class="failed-pages hidden" id="failed-pages">
|
||||
<h4>Failed Pages</h4>
|
||||
<div class="failed-list" id="failed-list">
|
||||
<!-- Failed page items will be listed here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console Output -->
|
||||
<div class="console-output hidden" id="console-output">
|
||||
<div class="console-header">
|
||||
<h4>Processing Log</h4>
|
||||
<button class="console-btn" onclick="clearConsole()">Clear</button>
|
||||
<button class="console-btn" onclick="toggleAutoScroll()">
|
||||
<span id="autoscroll-status">Auto-scroll: ON</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="console-content" id="console-content">
|
||||
<!-- Log messages will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Check -->
|
||||
<div id="batch-environment-check" class="hidden">
|
||||
<h3>System Environment</h3>
|
||||
<div id="batch-env-details"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Preview Modal -->
|
||||
<div id="batch-image-modal" class="image-modal">
|
||||
<div class="modal-content">
|
||||
<span class="modal-close" onclick="closeBatchImageModal()">×</span>
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab active" onclick="switchModalTab('visualization')">Visualization</button>
|
||||
<button class="modal-tab" onclick="switchModalTab('extracted')">Extracted Images</button>
|
||||
</div>
|
||||
<div id="modal-visualization-content" class="modal-tab-content active">
|
||||
<img id="modal-visualization-image" src="" alt="Page Visualization">
|
||||
</div>
|
||||
<div id="modal-extracted-content" class="modal-tab-content">
|
||||
<div id="modal-extracted-gallery" class="modal-gallery">
|
||||
<!-- Extracted images will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-info">
|
||||
<p id="modal-page-info"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="static/batch-processor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,176 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SCUB-Doc Intelligence Suite - Professional Document Analysis</title>
|
||||
<link rel="stylesheet" href="static/styles.css">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Docling Studio</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Professional Enterprise-Style Title -->
|
||||
<div class="title-enterprise">
|
||||
<div class="brand-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<h1>
|
||||
DocTags Intelligence Suite
|
||||
<span class="pro-badge">AI powered</span>
|
||||
</h1>
|
||||
<p class="tagline">Enterprise-grade document processing and analysis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Panel -->
|
||||
<div class="settings-panel">
|
||||
<div class="settings-row">
|
||||
<div class="form-group pdf-group">
|
||||
<div><label for="pdf_file">PDF Document</label><div id="pdf-load-status"></div></div>
|
||||
<select id="pdf_file" name="pdf_file">
|
||||
<option value="">Select a PDF file...</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="page_num">Page Number</label>
|
||||
<input type="number" id="page_num" name="page_num" value="1" min="1" placeholder="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="adjust">
|
||||
<input type="checkbox" id="adjust" name="adjust" checked>
|
||||
Auto-adjust coordinates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="progress-indicator">
|
||||
<div class="step-indicator" id="step-1">1</div>
|
||||
<div class="step-connector" id="connector-1"></div>
|
||||
<div class="step-indicator" id="step-2">2</div>
|
||||
<div class="step-connector" id="connector-2"></div>
|
||||
<div class="step-indicator" id="step-3">3</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Container -->
|
||||
<div class="tabs-container">
|
||||
<!-- Tab Buttons -->
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-button active" onclick="switchTab('analyzer')">
|
||||
Analyze Document
|
||||
</button>
|
||||
<button class="tab-button" onclick="switchTab('visualizer')">
|
||||
Visualize Structure
|
||||
</button>
|
||||
<button class="tab-button" onclick="switchTab('extractor')">
|
||||
Extract Images
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Contents -->
|
||||
<div id="analyzer-tab" class="tab-content active">
|
||||
<h3>Document Analysis</h3>
|
||||
<p>Extract comprehensive document structure from your PDF using advanced AI-powered analysis. This process identifies headers, paragraphs, images, tables, and other document elements with precise coordinate information.</p>
|
||||
|
||||
<button id="analyzer-btn" onclick="runScript('analyzer')">
|
||||
Start Analysis
|
||||
</button>
|
||||
|
||||
<div id="analyzer-status" class="status hidden"></div>
|
||||
|
||||
<!-- PDF Preview -->
|
||||
<div id="pdf-preview-container" class="hidden">
|
||||
<h4>PDF Preview - Page <span id="preview-page-num">1</span></h4>
|
||||
<div class="preview-controls">
|
||||
<button class="nav-btn" onclick="changePreviewPage(-1)">❮ Previous</button>
|
||||
<div class="page-selector">
|
||||
<label>Page:</label>
|
||||
<input type="number" id="preview-page-input" min="1" value="1" class="page-input">
|
||||
<button class="go-btn" onclick="goToPreviewPage()">Go</button>
|
||||
</div>
|
||||
<button class="nav-btn" onclick="changePreviewPage(1)">Next ❯</button>
|
||||
</div>
|
||||
<img id="pdf-preview-image" src="" alt="PDF Preview">
|
||||
</div>
|
||||
|
||||
<div class="tab-description">
|
||||
<strong>Analysis Process:</strong>
|
||||
<ul>
|
||||
<li>Converts your PDF page into a high-resolution image</li>
|
||||
<li>Applies machine learning to identify document structure</li>
|
||||
<li>Maps elements with precise pixel coordinates</li>
|
||||
<li>Generates structured DocTags format output</li>
|
||||
<li>Saves results to the <code>results/</code> directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="visualizer-tab" class="tab-content">
|
||||
<h3>Structure Visualization</h3>
|
||||
<p>Generate a visual representation of the analyzed document structure with color-coded bounding boxes around each detected element type.</p>
|
||||
|
||||
<button id="visualizer-btn" onclick="runScript('visualizer')" disabled>
|
||||
Create Visualization
|
||||
</button>
|
||||
|
||||
<div id="visualizer-status" class="status hidden"></div>
|
||||
|
||||
<div id="image-container" class="hidden">
|
||||
<h4>Visualization Result</h4>
|
||||
<img id="result-image" src="" alt="Document Structure Visualization">
|
||||
</div>
|
||||
|
||||
<div class="tab-description">
|
||||
<strong>Visualization Features:</strong>
|
||||
<ul>
|
||||
<li>Color-coded element boundaries (headers, text, images, tables)</li>
|
||||
<li>Element type labels for easy identification</li>
|
||||
<li>Accurate coordinate mapping and scaling</li>
|
||||
<li>High-quality PNG output for documentation</li>
|
||||
<li>Interactive overlay on original document</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="extractor-tab" class="tab-content">
|
||||
<h3>Image Extraction</h3>
|
||||
<p>Automatically extract and save individual images from your document based on the detected picture regions, complete with metadata and captions.</p>
|
||||
|
||||
<button id="extractor-btn" onclick="runScript('extractor')" disabled>
|
||||
Extract All Images
|
||||
</button>
|
||||
|
||||
<div id="extractor-status" class="status hidden"></div>
|
||||
|
||||
<!-- Extracted Images Gallery -->
|
||||
<div id="extracted-images-container" class="hidden">
|
||||
<h4>Extracted Images</h4>
|
||||
<div id="extracted-images-gallery" class="images-gallery">
|
||||
<!-- Images will be loaded here dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-description">
|
||||
<strong>Extraction Capabilities:</strong>
|
||||
<ul>
|
||||
<li>Precise image boundary detection and cropping</li>
|
||||
<li>Caption and metadata preservation</li>
|
||||
<li>Organized file naming and directory structure</li>
|
||||
<li>HTML index page for easy browsing</li>
|
||||
<li>Automatic quality optimization and resizing</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output Section -->
|
||||
<div id="output" class="output hidden"></div>
|
||||
</div>
|
||||
|
||||
<script src="static/app.js"></script>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/app/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
17
frontend/nginx.conf
Normal file
17
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
server {
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
}
|
||||
1601
frontend/package-lock.json
generated
Normal file
1601
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "docling-studio",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest --watch",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.6.4",
|
||||
"pinia": "^2.3.0",
|
||||
"marked": "^17.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#F97316"/>
|
||||
<text x="16" y="23" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="18" fill="white">D</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
89
frontend/src/app/App.vue
Normal file
89
frontend/src/app/App.vue
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<div class="app-layout">
|
||||
<AppSidebar />
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
import { AppSidebar } from '../shared/ui/index.js'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@300;400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* Dark mode palette - MistralAI Studio inspired */
|
||||
--bg: #0A0A0B;
|
||||
--bg-surface: #111113;
|
||||
--bg-elevated: #1A1A1D;
|
||||
--bg-hover: #222226;
|
||||
|
||||
--accent: #F97316;
|
||||
--accent-hover: #FB923C;
|
||||
--accent-muted: rgba(249, 115, 22, 0.15);
|
||||
|
||||
--text: #ECECEF;
|
||||
--text-secondary: #A1A1AA;
|
||||
--text-muted: #63636E;
|
||||
|
||||
--border: #27272A;
|
||||
--border-light: #3F3F46;
|
||||
|
||||
--success: #22C55E;
|
||||
--error: #EF4444;
|
||||
--warning: #EAB308;
|
||||
--info: #3B82F6;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
--sidebar-width: 240px;
|
||||
--transition: 150ms ease;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
9
frontend/src/app/main.js
Normal file
9
frontend/src/app/main.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { router } from './router/index.js'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
24
frontend/src/app/router/index.js
Normal file
24
frontend/src/app/router/index.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'studio',
|
||||
component: () => import('../../pages/StudioPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
component: () => import('../../pages/HistoryPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../../pages/SettingsPage.vue')
|
||||
}
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
20
frontend/src/features/analysis/api.js
Normal file
20
frontend/src/features/analysis/api.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { apiFetch } from '../../shared/api/http.js'
|
||||
|
||||
export function createAnalysis(documentId) {
|
||||
return apiFetch('/api/analyses', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ documentId })
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAnalyses() {
|
||||
return apiFetch('/api/analyses')
|
||||
}
|
||||
|
||||
export function fetchAnalysis(id) {
|
||||
return apiFetch(`/api/analyses/${id}`)
|
||||
}
|
||||
|
||||
export function deleteAnalysis(id) {
|
||||
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
6
frontend/src/features/analysis/index.js
Normal file
6
frontend/src/features/analysis/index.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { useAnalysisStore } from './store.js'
|
||||
export { default as AnalysisPanel } from './ui/AnalysisPanel.vue'
|
||||
export { default as ResultTabs } from './ui/ResultTabs.vue'
|
||||
export { default as MarkdownViewer } from './ui/MarkdownViewer.vue'
|
||||
export { default as StructureViewer } from './ui/StructureViewer.vue'
|
||||
export { default as ImageGallery } from './ui/ImageGallery.vue'
|
||||
90
frontend/src/features/analysis/store.js
Normal file
90
frontend/src/features/analysis/store.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as api from './api.js'
|
||||
|
||||
export const useAnalysisStore = defineStore('analysis', () => {
|
||||
const analyses = ref([])
|
||||
const currentAnalysis = ref(null)
|
||||
const running = ref(false)
|
||||
const pollingInterval = ref(null)
|
||||
|
||||
const currentPages = computed(() => {
|
||||
if (!currentAnalysis.value?.pagesJson) return []
|
||||
try {
|
||||
return JSON.parse(currentAnalysis.value.pagesJson)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
analyses.value = await api.fetchAnalyses()
|
||||
} catch (e) {
|
||||
console.error('Failed to load analyses', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function run(documentId) {
|
||||
running.value = true
|
||||
try {
|
||||
const analysis = await api.createAnalysis(documentId)
|
||||
currentAnalysis.value = analysis
|
||||
analyses.value.unshift(analysis)
|
||||
startPolling(analysis.id)
|
||||
return analysis
|
||||
} catch (e) {
|
||||
running.value = false
|
||||
console.error('Failed to start analysis', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(id) {
|
||||
stopPolling()
|
||||
pollingInterval.value = setInterval(async () => {
|
||||
try {
|
||||
const updated = await api.fetchAnalysis(id)
|
||||
currentAnalysis.value = updated
|
||||
// Update in list
|
||||
const idx = analyses.value.findIndex(a => a.id === id)
|
||||
if (idx !== -1) analyses.value[idx] = updated
|
||||
if (updated.status === 'COMPLETED' || updated.status === 'FAILED') {
|
||||
stopPolling()
|
||||
running.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Polling error', e)
|
||||
stopPolling()
|
||||
running.value = false
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollingInterval.value) {
|
||||
clearInterval(pollingInterval.value)
|
||||
pollingInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function select(id) {
|
||||
try {
|
||||
currentAnalysis.value = await api.fetchAnalysis(id)
|
||||
} catch (e) {
|
||||
console.error('Failed to load analysis', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
try {
|
||||
await api.deleteAnalysis(id)
|
||||
analyses.value = analyses.value.filter(a => a.id !== id)
|
||||
if (currentAnalysis.value?.id === id) currentAnalysis.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to delete analysis', e)
|
||||
}
|
||||
}
|
||||
|
||||
return { analyses, currentAnalysis, currentPages, running, load, run, select, remove, stopPolling }
|
||||
})
|
||||
150
frontend/src/features/analysis/ui/AnalysisPanel.vue
Normal file
150
frontend/src/features/analysis/ui/AnalysisPanel.vue
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<template>
|
||||
<div class="analysis-panel">
|
||||
<div class="panel-section">
|
||||
<DocumentUpload />
|
||||
</div>
|
||||
|
||||
<div class="panel-section" v-if="documentStore.documents.length">
|
||||
<label class="section-label">Documents</label>
|
||||
<DocumentList />
|
||||
</div>
|
||||
|
||||
<div class="panel-section" v-if="selectedDoc">
|
||||
<label class="section-label">Preview</label>
|
||||
<PagePreview
|
||||
:document-id="selectedDoc.id"
|
||||
:page="currentPage"
|
||||
:page-count="selectedDoc.pageCount"
|
||||
@update:page="currentPage = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions" v-if="selectedDoc">
|
||||
<button
|
||||
class="btn-analyze"
|
||||
:disabled="analysisStore.running"
|
||||
@click="runAnalysis"
|
||||
>
|
||||
<div v-if="analysisStore.running" class="spinner" />
|
||||
<svg v-else viewBox="0 0 20 20" fill="currentColor" class="btn-icon">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{{ analysisStore.running ? 'Analyzing...' : 'Analyze' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-section" v-if="analysisStore.currentAnalysis">
|
||||
<label class="section-label">Status</label>
|
||||
<div class="status-row">
|
||||
<span class="status-badge" :class="statusClass">
|
||||
{{ analysisStore.currentAnalysis.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useDocumentStore } from '../../document/store.js'
|
||||
import { useAnalysisStore } from '../store.js'
|
||||
import { DocumentUpload, DocumentList, PagePreview } from '../../document/index.js'
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const analysisStore = useAnalysisStore()
|
||||
const currentPage = ref(1)
|
||||
|
||||
const selectedDoc = computed(() => {
|
||||
return documentStore.documents.find(d => d.id === documentStore.selectedId)
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const status = analysisStore.currentAnalysis?.status
|
||||
return {
|
||||
'status-pending': status === 'PENDING',
|
||||
'status-running': status === 'RUNNING',
|
||||
'status-completed': status === 'COMPLETED',
|
||||
'status-failed': status === 'FAILED'
|
||||
}
|
||||
})
|
||||
|
||||
async function runAnalysis() {
|
||||
if (!documentStore.selectedId) return
|
||||
await analysisStore.run(documentStore.selectedId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analysis-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.btn-analyze {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.btn-analyze:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.btn-analyze:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-icon { width: 18px; height: 18px; }
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); }
|
||||
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); }
|
||||
.status-completed { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
</style>
|
||||
109
frontend/src/features/analysis/ui/ImageGallery.vue
Normal file
109
frontend/src/features/analysis/ui/ImageGallery.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<div class="image-gallery">
|
||||
<div v-if="images.length === 0" class="gallery-empty">
|
||||
No images detected in this document
|
||||
</div>
|
||||
<div v-else class="gallery-grid">
|
||||
<div
|
||||
v-for="(img, idx) in images"
|
||||
:key="idx"
|
||||
class="gallery-card"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="card-type">Picture</span>
|
||||
<span class="card-page">Page {{ img.page }}</span>
|
||||
</div>
|
||||
<div class="card-content" v-if="img.content">
|
||||
{{ img.content.substring(0, 100) }}
|
||||
</div>
|
||||
<div class="card-bbox">
|
||||
<span class="bbox-label">bbox:</span>
|
||||
<span class="bbox-value">{{ img.bbox.map(v => Math.round(v)).join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
pages: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const images = computed(() => {
|
||||
const result = []
|
||||
for (const page of props.pages) {
|
||||
for (const el of page.elements) {
|
||||
if (el.type === 'picture') {
|
||||
result.push({ ...el, page: page.page_number })
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-gallery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gallery-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gallery-card {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-type {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #22C55E;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card-page {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-bbox {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bbox-label { margin-right: 4px; }
|
||||
</style>
|
||||
89
frontend/src/features/analysis/ui/MarkdownViewer.vue
Normal file
89
frontend/src/features/analysis/ui/MarkdownViewer.vue
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<div class="markdown-viewer" v-html="rendered" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const props = defineProps({ content: String })
|
||||
|
||||
const rendered = computed(() => {
|
||||
if (!props.content) return '<p class="empty">No markdown content</p>'
|
||||
return marked.parse(props.content)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-viewer {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(h1),
|
||||
.markdown-viewer :deep(h2),
|
||||
.markdown-viewer :deep(h3) {
|
||||
color: var(--text);
|
||||
margin: 24px 0 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(h1) { font-size: 24px; }
|
||||
.markdown-viewer :deep(h2) { font-size: 20px; }
|
||||
.markdown-viewer :deep(h3) { font-size: 16px; }
|
||||
|
||||
.markdown-viewer :deep(p) { margin: 8px 0; }
|
||||
|
||||
.markdown-viewer :deep(code) {
|
||||
background: var(--bg-elevated);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(pre) {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(th),
|
||||
.markdown-viewer :deep(td) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(th) {
|
||||
background: var(--bg-elevated);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.markdown-viewer :deep(.empty) {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
130
frontend/src/features/analysis/ui/ResultTabs.vue
Normal file
130
frontend/src/features/analysis/ui/ResultTabs.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<template>
|
||||
<div class="result-tabs" v-if="store.currentAnalysis?.status === 'COMPLETED'">
|
||||
<div class="tabs-header">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<MarkdownViewer v-if="activeTab === 'markdown'" :content="store.currentAnalysis.contentMarkdown" />
|
||||
<div v-else-if="activeTab === 'html'" class="html-viewer" v-html="store.currentAnalysis.contentHtml" />
|
||||
<StructureViewer
|
||||
v-else-if="activeTab === 'structure'"
|
||||
:pages="store.currentPages"
|
||||
:document-id="store.currentAnalysis.documentId"
|
||||
/>
|
||||
<ImageGallery v-else-if="activeTab === 'images'" :pages="store.currentPages" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
|
||||
<div class="spinner-large" />
|
||||
<span>Analysis in progress...</span>
|
||||
</div>
|
||||
<div v-else-if="store.currentAnalysis?.status === 'FAILED'" class="result-placeholder error">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="error-icon"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
<span>{{ store.currentAnalysis.errorMessage || 'Analysis failed' }}</span>
|
||||
</div>
|
||||
<div v-else class="result-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
|
||||
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
<span>Upload a document and run an analysis</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAnalysisStore } from '../store.js'
|
||||
import MarkdownViewer from './MarkdownViewer.vue'
|
||||
import StructureViewer from './StructureViewer.vue'
|
||||
import ImageGallery from './ImageGallery.vue'
|
||||
|
||||
const store = useAnalysisStore()
|
||||
const activeTab = ref('markdown')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'markdown', label: 'Markdown' },
|
||||
{ id: 'html', label: 'HTML' },
|
||||
{ id: 'structure', label: 'Structure' },
|
||||
{ id: 'images', label: 'Images' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.tab-btn:hover { color: var(--text-secondary); }
|
||||
.tab-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.html-viewer {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.result-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-placeholder.error { color: var(--error); }
|
||||
.error-icon { width: 32px; height: 32px; }
|
||||
.empty-icon { width: 48px; height: 48px; color: var(--border-light); }
|
||||
|
||||
.spinner-large {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
318
frontend/src/features/analysis/ui/StructureViewer.vue
Normal file
318
frontend/src/features/analysis/ui/StructureViewer.vue
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
<template>
|
||||
<div class="structure-viewer">
|
||||
<!-- Page selector -->
|
||||
<div class="page-selector" v-if="pages.length > 1">
|
||||
<button
|
||||
v-for="p in pages"
|
||||
:key="p.page_number"
|
||||
class="page-btn"
|
||||
:class="{ active: selectedPage === p.page_number }"
|
||||
@click="selectedPage = p.page_number"
|
||||
>
|
||||
{{ p.page_number }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="legend">
|
||||
<button
|
||||
v-for="[type, color] in Object.entries(ELEMENT_COLORS)"
|
||||
:key="type"
|
||||
class="legend-item"
|
||||
:class="{ dimmed: hiddenTypes.has(type) }"
|
||||
@click="toggleType(type)"
|
||||
>
|
||||
<span class="legend-dot" :style="{ background: color }" />
|
||||
<span>{{ type }}</span>
|
||||
<span class="legend-count">{{ countElements(type) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Canvas overlay -->
|
||||
<div class="canvas-container" ref="containerRef">
|
||||
<img
|
||||
v-if="documentId"
|
||||
:src="previewUrl"
|
||||
class="page-image"
|
||||
ref="imageRef"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="overlay-canvas"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseleave="hoveredElement = null"
|
||||
/>
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
v-if="hoveredElement"
|
||||
class="tooltip"
|
||||
:style="tooltipStyle"
|
||||
>
|
||||
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
|
||||
{{ hoveredElement.type }}
|
||||
</span>
|
||||
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, reactive } from 'vue'
|
||||
import { getPreviewUrl } from '../../document/api.js'
|
||||
|
||||
const ELEMENT_COLORS = {
|
||||
section_header: '#F97316',
|
||||
text: '#3B82F6',
|
||||
table: '#8B5CF6',
|
||||
picture: '#22C55E',
|
||||
list: '#06B6D4',
|
||||
formula: '#EC4899',
|
||||
caption: '#EAB308'
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
pages: { type: Array, default: () => [] },
|
||||
documentId: String
|
||||
})
|
||||
|
||||
const selectedPage = ref(1)
|
||||
const hiddenTypes = reactive(new Set())
|
||||
const containerRef = ref(null)
|
||||
const imageRef = ref(null)
|
||||
const canvasRef = ref(null)
|
||||
const hoveredElement = ref(null)
|
||||
const tooltipStyle = ref({})
|
||||
const imageSize = ref({ width: 0, height: 0 })
|
||||
|
||||
const currentPageData = computed(() => {
|
||||
return props.pages.find(p => p.page_number === selectedPage.value)
|
||||
})
|
||||
|
||||
const visibleElements = computed(() => {
|
||||
if (!currentPageData.value) return []
|
||||
return currentPageData.value.elements.filter(e => !hiddenTypes.has(e.type))
|
||||
})
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
if (!props.documentId) return null
|
||||
return getPreviewUrl(props.documentId, selectedPage.value)
|
||||
})
|
||||
|
||||
function toggleType(type) {
|
||||
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
|
||||
else hiddenTypes.add(type)
|
||||
drawOverlay()
|
||||
}
|
||||
|
||||
function countElements(type) {
|
||||
if (!currentPageData.value) return 0
|
||||
return currentPageData.value.elements.filter(e => e.type === type).length
|
||||
}
|
||||
|
||||
function onImageLoad() {
|
||||
const img = imageRef.value
|
||||
if (!img) return
|
||||
imageSize.value = { width: img.naturalWidth, height: img.naturalHeight }
|
||||
nextTick(drawOverlay)
|
||||
}
|
||||
|
||||
function drawOverlay() {
|
||||
const canvas = canvasRef.value
|
||||
const img = imageRef.value
|
||||
if (!canvas || !img) return
|
||||
|
||||
canvas.width = img.clientWidth
|
||||
canvas.height = img.clientHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const page = currentPageData.value
|
||||
if (!page) return
|
||||
|
||||
const scaleX = img.clientWidth / page.width
|
||||
const scaleY = img.clientHeight / page.height
|
||||
|
||||
for (const el of visibleElements.value) {
|
||||
const [x0, y0, x1, y1] = el.bbox
|
||||
const rx = x0 * scaleX
|
||||
const ry = y0 * scaleY
|
||||
const rw = (x1 - x0) * scaleX
|
||||
const rh = (y1 - y0) * scaleY
|
||||
|
||||
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
||||
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(rx, ry, rw, rh)
|
||||
|
||||
ctx.fillStyle = color + '20'
|
||||
ctx.fillRect(rx, ry, rw, rh)
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
const canvas = canvasRef.value
|
||||
const page = currentPageData.value
|
||||
const img = imageRef.value
|
||||
if (!canvas || !page || !img) return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const mx = e.clientX - rect.left
|
||||
const my = e.clientY - rect.top
|
||||
|
||||
const scaleX = img.clientWidth / page.width
|
||||
const scaleY = img.clientHeight / page.height
|
||||
|
||||
let found = null
|
||||
for (const el of visibleElements.value) {
|
||||
const [x0, y0, x1, y1] = el.bbox
|
||||
const rx = x0 * scaleX
|
||||
const ry = y0 * scaleY
|
||||
const rw = (x1 - x0) * scaleX
|
||||
const rh = (y1 - y0) * scaleY
|
||||
|
||||
if (mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh) {
|
||||
found = el
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
hoveredElement.value = found
|
||||
if (found) {
|
||||
tooltipStyle.value = {
|
||||
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
|
||||
top: `${my + 12}px`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
||||
nextTick(drawOverlay)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.structure-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.page-btn:hover { background: var(--bg-hover); }
|
||||
.page-btn.active {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.legend-item:hover { background: var(--bg-hover); }
|
||||
.legend-item.dimmed { opacity: 0.4; }
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-count {
|
||||
color: var(--text-muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overlay-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
max-width: 250px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.tooltip-type {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
27
frontend/src/features/document/api.js
Normal file
27
frontend/src/features/document/api.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { apiFetch } from '../../shared/api/http.js'
|
||||
|
||||
export function fetchDocuments() {
|
||||
return apiFetch('/api/documents')
|
||||
}
|
||||
|
||||
export function fetchDocument(id) {
|
||||
return apiFetch(`/api/documents/${id}`)
|
||||
}
|
||||
|
||||
export async function uploadDocument(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return apiFetch('/api/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
skipContentType: true
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteDocument(id) {
|
||||
return apiFetch(`/api/documents/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function getPreviewUrl(id, page = 1, dpi = 150) {
|
||||
return `/api/documents/${id}/preview?page=${page}&dpi=${dpi}`
|
||||
}
|
||||
4
frontend/src/features/document/index.js
Normal file
4
frontend/src/features/document/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { useDocumentStore } from './store.js'
|
||||
export { default as DocumentUpload } from './ui/DocumentUpload.vue'
|
||||
export { default as DocumentList } from './ui/DocumentList.vue'
|
||||
export { default as PagePreview } from './ui/PagePreview.vue'
|
||||
48
frontend/src/features/document/store.js
Normal file
48
frontend/src/features/document/store.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from './api.js'
|
||||
|
||||
export const useDocumentStore = defineStore('document', () => {
|
||||
const documents = ref([])
|
||||
const selectedId = ref(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
documents.value = await api.fetchDocuments()
|
||||
} catch (e) {
|
||||
console.error('Failed to load documents', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(file) {
|
||||
uploading.value = true
|
||||
try {
|
||||
const doc = await api.uploadDocument(file)
|
||||
documents.value.unshift(doc)
|
||||
selectedId.value = doc.id
|
||||
return doc
|
||||
} catch (e) {
|
||||
console.error('Failed to upload document', e)
|
||||
throw e
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
try {
|
||||
await api.deleteDocument(id)
|
||||
documents.value = documents.value.filter(d => d.id !== id)
|
||||
if (selectedId.value === id) selectedId.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to delete document', e)
|
||||
}
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
return { documents, selectedId, uploading, load, upload, remove, select }
|
||||
})
|
||||
112
frontend/src/features/document/ui/DocumentList.vue
Normal file
112
frontend/src/features/document/ui/DocumentList.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<div class="doc-list" v-if="store.documents.length">
|
||||
<div
|
||||
v-for="doc in store.documents"
|
||||
:key="doc.id"
|
||||
class="doc-item"
|
||||
:class="{ selected: store.selectedId === doc.id }"
|
||||
@click="store.select(doc.id)"
|
||||
>
|
||||
<div class="doc-info">
|
||||
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div class="doc-meta">
|
||||
<span class="doc-name">{{ doc.filename }}</span>
|
||||
<span class="doc-size">{{ formatSize(doc.fileSize) }}{{ doc.pageCount ? ` — ${doc.pageCount} pages` : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="doc-delete" @click.stop="store.remove(doc.id)" title="Delete">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDocumentStore } from '../store.js'
|
||||
const store = useDocumentStore()
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.doc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.doc-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.doc-item.selected {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.doc-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.doc-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.doc-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.doc-item:hover .doc-delete { opacity: 1; }
|
||||
.doc-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
|
||||
.doc-delete svg { width: 14px; height: 14px; }
|
||||
</style>
|
||||
109
frontend/src/features/document/ui/DocumentUpload.vue
Normal file
109
frontend/src/features/document/ui/DocumentUpload.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<div
|
||||
class="upload-zone"
|
||||
:class="{ dragging, uploading: store.uploading }"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<input ref="fileInput" type="file" accept=".pdf" hidden @change="onFileSelect" />
|
||||
<div v-if="store.uploading" class="upload-state">
|
||||
<div class="spinner" />
|
||||
<span>Uploading...</span>
|
||||
</div>
|
||||
<div v-else class="upload-state">
|
||||
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
|
||||
</svg>
|
||||
<span class="upload-text">Drop a PDF here or click to upload</span>
|
||||
<span class="upload-hint">Max 50MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useDocumentStore } from '../store.js'
|
||||
|
||||
const store = useDocumentStore()
|
||||
const fileInput = ref(null)
|
||||
const dragging = ref(false)
|
||||
|
||||
function openFilePicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onFileSelect(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) await store.upload(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function onDrop(e) {
|
||||
dragging.value = false
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file && file.type === 'application/pdf') {
|
||||
await store.upload(file)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-zone {
|
||||
border: 2px dashed var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.upload-zone:hover,
|
||||
.upload-zone.dragging {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.upload-zone.uploading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.upload-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
99
frontend/src/features/document/ui/PagePreview.vue
Normal file
99
frontend/src/features/document/ui/PagePreview.vue
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div class="page-preview" v-if="documentId">
|
||||
<div class="preview-header">
|
||||
<span class="preview-label">Page {{ page }}</span>
|
||||
<div class="preview-nav">
|
||||
<button class="nav-btn" :disabled="page <= 1" @click="$emit('update:page', page - 1)">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
<button class="nav-btn" :disabled="pageCount && page >= pageCount" @click="$emit('update:page', page + 1)">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-image-wrapper">
|
||||
<img
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="`Page ${page}`"
|
||||
class="preview-image"
|
||||
@load="$emit('imageLoaded', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { getPreviewUrl } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
documentId: String,
|
||||
page: { type: Number, default: 1 },
|
||||
pageCount: Number
|
||||
})
|
||||
|
||||
defineEmits(['update:page', 'imageLoaded'])
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
if (!props.documentId) return null
|
||||
return getPreviewUrl(props.documentId, props.page)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.preview-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.nav-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn svg { width: 16px; height: 16px; }
|
||||
|
||||
.preview-image-wrapper {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
9
frontend/src/features/history/api.js
Normal file
9
frontend/src/features/history/api.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { apiFetch } from '../../shared/api/http.js'
|
||||
|
||||
export function fetchAnalyses() {
|
||||
return apiFetch('/api/analyses')
|
||||
}
|
||||
|
||||
export function deleteAnalysis(id) {
|
||||
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
2
frontend/src/features/history/index.js
Normal file
2
frontend/src/features/history/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useHistoryStore } from './store.js'
|
||||
export { default as HistoryList } from './ui/HistoryList.vue'
|
||||
26
frontend/src/features/history/store.js
Normal file
26
frontend/src/features/history/store.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from './api.js'
|
||||
|
||||
export const useHistoryStore = defineStore('history', () => {
|
||||
const analyses = ref([])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
analyses.value = await api.fetchAnalyses()
|
||||
} catch (e) {
|
||||
console.error('Failed to load history', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
try {
|
||||
await api.deleteAnalysis(id)
|
||||
analyses.value = analyses.value.filter(a => a.id !== id)
|
||||
} catch (e) {
|
||||
console.error('Failed to delete analysis', e)
|
||||
}
|
||||
}
|
||||
|
||||
return { analyses, load, remove }
|
||||
})
|
||||
141
frontend/src/features/history/ui/HistoryList.vue
Normal file
141
frontend/src/features/history/ui/HistoryList.vue
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<template>
|
||||
<div class="history-list">
|
||||
<div v-if="store.analyses.length === 0" class="history-empty">
|
||||
No analyses yet. Go to Studio to analyze your first document.
|
||||
</div>
|
||||
<div v-else class="history-items">
|
||||
<div
|
||||
v-for="analysis in store.analyses"
|
||||
:key="analysis.id"
|
||||
class="history-item"
|
||||
>
|
||||
<div class="item-main">
|
||||
<div class="item-header">
|
||||
<span class="item-filename">{{ analysis.documentFilename }}</span>
|
||||
<span class="item-status" :class="statusClass(analysis.status)">
|
||||
{{ analysis.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span>{{ formatDate(analysis.createdAt) }}</span>
|
||||
<span v-if="analysis.completedAt"> — {{ duration(analysis) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="item-delete" @click="store.remove(analysis.id)" title="Delete">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useHistoryStore } from '../store.js'
|
||||
|
||||
const store = useHistoryStore()
|
||||
|
||||
function statusClass(status) {
|
||||
return {
|
||||
'status-pending': status === 'PENDING',
|
||||
'status-running': status === 'RUNNING',
|
||||
'status-completed': status === 'COMPLETED',
|
||||
'status-failed': status === 'FAILED'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
function duration(analysis) {
|
||||
if (!analysis.startedAt || !analysis.completedAt) return ''
|
||||
const ms = new Date(analysis.completedAt) - new Date(analysis.startedAt)
|
||||
const secs = Math.round(ms / 1000)
|
||||
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 60px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.history-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.history-item:hover { background: var(--bg-hover); }
|
||||
|
||||
.item-main { flex: 1; min-width: 0; }
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item-filename {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-status {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-pending { background: rgba(234, 179, 8, 0.15); color: var(--warning); }
|
||||
.status-running { background: rgba(59, 130, 246, 0.15); color: var(--info); }
|
||||
.status-completed { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.status-failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
|
||||
.item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.item-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.history-item:hover .item-delete { opacity: 1; }
|
||||
.item-delete:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); }
|
||||
.item-delete svg { width: 16px; height: 16px; }
|
||||
</style>
|
||||
2
frontend/src/features/settings/index.js
Normal file
2
frontend/src/features/settings/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useSettingsStore } from './store.js'
|
||||
export { default as SettingsPanel } from './ui/SettingsPanel.vue'
|
||||
9
frontend/src/features/settings/store.js
Normal file
9
frontend/src/features/settings/store.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const parserUrl = ref('http://localhost:8000')
|
||||
const backendUrl = ref('http://localhost:8081')
|
||||
|
||||
return { parserUrl, backendUrl }
|
||||
})
|
||||
60
frontend/src/features/settings/ui/SettingsPanel.vue
Normal file
60
frontend/src/features/settings/ui/SettingsPanel.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div class="settings-panel">
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">Backend URL</label>
|
||||
<input class="setting-input" v-model="store.backendUrl" readonly />
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">Document Parser URL</label>
|
||||
<input class="setting-input" v-model="store.parserUrl" readonly />
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">Version</label>
|
||||
<span class="setting-value">0.1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useSettingsStore } from '../store.js'
|
||||
const store = useSettingsStore()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
44
frontend/src/pages/HistoryPage.vue
Normal file
44
frontend/src/pages/HistoryPage.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div class="history-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Analysis History</h1>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<HistoryList />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { HistoryList, useHistoryStore } from '../features/history/index.js'
|
||||
|
||||
const store = useHistoryStore()
|
||||
onMounted(() => store.load())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/pages/SettingsPage.vue
Normal file
41
frontend/src/pages/SettingsPage.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Settings</h1>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { SettingsPanel } from '../features/settings/index.js'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
69
frontend/src/pages/StudioPage.vue
Normal file
69
frontend/src/pages/StudioPage.vue
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<div class="studio-page">
|
||||
<div class="studio-header">
|
||||
<h1 class="studio-title">Document Analysis</h1>
|
||||
</div>
|
||||
<div class="studio-content">
|
||||
<div class="input-panel">
|
||||
<AnalysisPanel />
|
||||
</div>
|
||||
<div class="output-panel">
|
||||
<ResultTabs />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useDocumentStore } from '../features/document/store.js'
|
||||
import { useAnalysisStore } from '../features/analysis/store.js'
|
||||
import { AnalysisPanel, ResultTabs } from '../features/analysis/index.js'
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const analysisStore = useAnalysisStore()
|
||||
|
||||
onMounted(() => {
|
||||
documentStore.load()
|
||||
analysisStore.load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.studio-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.studio-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.studio-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.studio-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.input-panel {
|
||||
border-right: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.output-panel {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
15
frontend/src/shared/api/http.js
Normal file
15
frontend/src/shared/api/http.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export async function apiFetch(url, options = {}) {
|
||||
const headers = { ...options.headers }
|
||||
|
||||
if (!options.skipContentType) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
if (!response.ok) throw new Error(`API error: ${response.status}`)
|
||||
if (response.status === 204) return null
|
||||
return response.json()
|
||||
}
|
||||
125
frontend/src/shared/ui/AppSidebar.vue
Normal file
125
frontend/src/shared/ui/AppSidebar.vue
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">D</span>
|
||||
<span class="logo-text">Docling Studio</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<RouterLink to="/" class="nav-item" :class="{ active: route.name === 'studio' }">
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.727 1.17 1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zm5.99 7.176A9.026 9.026 0 007 15.96v-4.5l.61.26a2.5 2.5 0 001.98 0l.61-.26v4.5a9.026 9.026 0 00-1.7.613z"/></svg>
|
||||
<span>Studio</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/history" class="nav-item" :class="{ active: route.name === 'history' }">
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
||||
<span>History</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/settings" class="nav-item" :class="{ active: route.name === 'settings' }">
|
||||
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>
|
||||
<span>Settings</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">v0.1.0</span>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
1
frontend/src/shared/ui/index.js
Normal file
1
frontend/src/shared/ui/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as AppSidebar } from './AppSidebar.vue'
|
||||
|
|
@ -1,479 +0,0 @@
|
|||
// DocTags Application State Management
|
||||
const appState = {
|
||||
activeTasks: {},
|
||||
pollingInterval: null,
|
||||
generatedOutputs: {
|
||||
visualizer: null,
|
||||
extractor: false
|
||||
}
|
||||
};
|
||||
|
||||
// UI Helper Functions
|
||||
const ui = {
|
||||
show(elementId) {
|
||||
document.getElementById(elementId).classList.remove('hidden');
|
||||
},
|
||||
|
||||
hide(elementId) {
|
||||
document.getElementById(elementId).classList.add('hidden');
|
||||
},
|
||||
|
||||
setText(elementId, text) {
|
||||
document.getElementById(elementId).textContent = text;
|
||||
},
|
||||
|
||||
setHtml(elementId, html) {
|
||||
document.getElementById(elementId).innerHTML = html;
|
||||
},
|
||||
|
||||
getValue(elementId) {
|
||||
return document.getElementById(elementId).value;
|
||||
},
|
||||
|
||||
setValue(elementId, value) {
|
||||
document.getElementById(elementId).value = value;
|
||||
},
|
||||
|
||||
disable(elementId) {
|
||||
document.getElementById(elementId).disabled = true;
|
||||
},
|
||||
|
||||
enable(elementId) {
|
||||
document.getElementById(elementId).disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// PDF Preview Functions
|
||||
function loadPDFPreview() {
|
||||
const pdfFile = ui.getValue('pdf_file');
|
||||
const pageNum = ui.getValue('page_num');
|
||||
|
||||
if (!pdfFile) {
|
||||
ui.hide('pdf-preview-container');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.setText('preview-page-num', pageNum);
|
||||
ui.setValue('preview-page-input', pageNum);
|
||||
|
||||
const previewImg = document.getElementById('pdf-preview-image');
|
||||
previewImg.src = `/pdf-preview/${encodeURIComponent(pdfFile)}/${pageNum}`;
|
||||
ui.show('pdf-preview-container');
|
||||
|
||||
previewImg.onerror = function() {
|
||||
this.alt = 'Failed to load PDF preview';
|
||||
console.error('Failed to load PDF preview');
|
||||
};
|
||||
}
|
||||
|
||||
function changePreviewPage(delta) {
|
||||
const currentPage = parseInt(ui.getValue('page_num'));
|
||||
const newPage = Math.max(1, currentPage + delta);
|
||||
ui.setValue('page_num', newPage);
|
||||
loadPDFPreview();
|
||||
}
|
||||
|
||||
function goToPreviewPage() {
|
||||
const pageInput = document.getElementById('preview-page-input');
|
||||
const pageNum = parseInt(pageInput.value);
|
||||
|
||||
if (pageNum && pageNum > 0) {
|
||||
ui.setValue('page_num', pageNum);
|
||||
loadPDFPreview();
|
||||
} else {
|
||||
pageInput.value = ui.getValue('page_num');
|
||||
}
|
||||
}
|
||||
|
||||
// Tab Management
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab contents and deactivate buttons
|
||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-button').forEach(button => button.classList.remove('active'));
|
||||
|
||||
// Show selected tab
|
||||
document.getElementById(tabName + '-tab').classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Show previously generated content
|
||||
if (tabName === 'analyzer') {
|
||||
loadPDFPreview();
|
||||
} else if (tabName === 'visualizer' && appState.generatedOutputs.visualizer) {
|
||||
document.getElementById('result-image').src = appState.generatedOutputs.visualizer + '?t=' + Date.now();
|
||||
ui.show('image-container');
|
||||
} else if (tabName === 'extractor' && appState.generatedOutputs.extractor) {
|
||||
loadExtractedImages();
|
||||
}
|
||||
}
|
||||
|
||||
// Progress Management
|
||||
function updateProgress(completedSteps) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const stepIndicator = document.getElementById(`step-${i}`);
|
||||
const connector = document.getElementById(`connector-${i}`);
|
||||
|
||||
if (i <= completedSteps) {
|
||||
stepIndicator.classList.add('completed');
|
||||
stepIndicator.classList.remove('active');
|
||||
if (connector) connector.classList.add('completed');
|
||||
} else if (i === completedSteps + 1) {
|
||||
stepIndicator.classList.add('active');
|
||||
stepIndicator.classList.remove('completed');
|
||||
} else {
|
||||
stepIndicator.classList.remove('completed', 'active');
|
||||
if (connector) connector.classList.remove('completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Task Management
|
||||
function startPolling() {
|
||||
if (!appState.pollingInterval) {
|
||||
appState.pollingInterval = setInterval(pollTasks, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (Object.keys(appState.activeTasks).length === 0 && appState.pollingInterval) {
|
||||
clearInterval(appState.pollingInterval);
|
||||
appState.pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTasks() {
|
||||
for (const taskId in appState.activeTasks) {
|
||||
try {
|
||||
const response = await fetch(`/task-status/${taskId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Debug log
|
||||
console.log(`Task ${taskId} status:`, data);
|
||||
|
||||
updateTaskStatus(taskId, data);
|
||||
} catch (error) {
|
||||
console.error('Error polling task:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateTaskStatus(taskId, data) {
|
||||
const taskInfo = appState.activeTasks[taskId];
|
||||
const statusElement = document.getElementById(`${taskInfo.type}-status`);
|
||||
|
||||
if (data.done) {
|
||||
if (data.success) {
|
||||
handleTaskSuccess(taskId, taskInfo, data, statusElement);
|
||||
} else {
|
||||
handleTaskFailure(taskId, data, statusElement);
|
||||
}
|
||||
delete appState.activeTasks[taskId];
|
||||
stopPolling();
|
||||
} else {
|
||||
ui.setHtml(`${taskInfo.type}-status`, '<div class="loader"></div><span class="working">Running...</span>');
|
||||
ui.show(`${taskInfo.type}-status`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskSuccess(taskId, taskInfo, data, statusElement) {
|
||||
ui.setHtml(`${taskInfo.type}-status`, '<span class="success">✓ Completed successfully!</span>');
|
||||
ui.show(`${taskInfo.type}-status`);
|
||||
ui.enable(`${taskInfo.type}-btn`);
|
||||
|
||||
// Display output
|
||||
ui.setText('output', data.output);
|
||||
ui.show('output');
|
||||
|
||||
// Handle specific task types
|
||||
if (taskInfo.type === 'visualizer' && data.image_file) {
|
||||
appState.generatedOutputs.visualizer = '/' + data.image_file;
|
||||
document.getElementById('result-image').src = appState.generatedOutputs.visualizer + '?t=' + Date.now();
|
||||
ui.show('image-container');
|
||||
} else if (taskInfo.type === 'extractor') {
|
||||
setTimeout(loadExtractedImages, 1000);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progressMap = { 'analyzer': 1, 'visualizer': 2, 'extractor': 3 };
|
||||
updateProgress(progressMap[taskInfo.type]);
|
||||
|
||||
// Enable next steps
|
||||
if (taskInfo.type === 'analyzer') {
|
||||
ui.enable('visualizer-btn');
|
||||
} else if (taskInfo.type === 'visualizer') {
|
||||
ui.enable('extractor-btn');
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskFailure(taskId, data, statusElement) {
|
||||
const taskInfo = appState.activeTasks[taskId];
|
||||
ui.setHtml(`${taskInfo.type}-status`, '<span class="error">✗ Failed: ' + (data.error || 'Unknown error') + '</span>');
|
||||
ui.show(`${taskInfo.type}-status`);
|
||||
ui.enable(`${taskInfo.type}-btn`);
|
||||
|
||||
ui.setText('output', 'Error: ' + (data.error || 'Unknown error'));
|
||||
ui.show('output');
|
||||
}
|
||||
|
||||
// Script Execution
|
||||
async function runScript(script) {
|
||||
const pdfFile = ui.getValue('pdf_file');
|
||||
const pageNum = ui.getValue('page_num');
|
||||
const adjust = document.getElementById('adjust').checked;
|
||||
|
||||
if (!pdfFile) {
|
||||
alert('Please select a PDF file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable button and show status
|
||||
ui.disable(`${script}-btn`);
|
||||
ui.setHtml(`${script}-status`, '<div class="loader"></div><span class="working">Starting...</span>');
|
||||
ui.show(`${script}-status`);
|
||||
ui.hide('output');
|
||||
|
||||
// Reset outputs for analyzer
|
||||
if (script === 'analyzer') {
|
||||
ui.hide('image-container');
|
||||
ui.hide('extracted-images-container');
|
||||
appState.generatedOutputs = { visualizer: null, extractor: false };
|
||||
ui.disable('visualizer-btn');
|
||||
ui.disable('extractor-btn');
|
||||
updateProgress(0);
|
||||
}
|
||||
|
||||
// Create form data
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_file', pdfFile);
|
||||
formData.append('page_num', pageNum);
|
||||
formData.append('adjust', adjust);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/run-${script}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.task_id) {
|
||||
appState.activeTasks[data.task_id] = {
|
||||
type: script,
|
||||
pageNum: pageNum
|
||||
};
|
||||
startPolling();
|
||||
|
||||
ui.setText('output', data.message || 'Task started, please wait...');
|
||||
ui.show('output');
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to start task');
|
||||
}
|
||||
} catch (error) {
|
||||
ui.setHtml(`${script}-status`, '<span class="error">✗ ' + error.message + '</span>');
|
||||
ui.enable(`${script}-btn`);
|
||||
ui.setText('output', 'Error: ' + error.message);
|
||||
ui.show('output');
|
||||
}
|
||||
}
|
||||
|
||||
// Image Gallery Functions
|
||||
async function loadExtractedImages() {
|
||||
const imageGallery = document.getElementById('extracted-images-gallery');
|
||||
|
||||
try {
|
||||
const response = await fetch('/results/pictures/index.html');
|
||||
if (!response.ok) throw new Error('No extracted images found');
|
||||
|
||||
const html = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const imageCards = doc.querySelectorAll('.picture-card');
|
||||
|
||||
if (imageCards.length === 0) {
|
||||
imageGallery.innerHTML = '<div class="no-images">No images were extracted from this page.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let galleryHTML = '';
|
||||
imageCards.forEach((card, index) => {
|
||||
const img = card.querySelector('img');
|
||||
const caption = card.querySelector('.picture-caption');
|
||||
const coords = card.querySelector('.picture-coords');
|
||||
|
||||
if (img) {
|
||||
const imgSrc = img.getAttribute('src');
|
||||
const pictureId = index + 1;
|
||||
const captionText = caption ? caption.textContent : '';
|
||||
const coordsText = coords ? coords.textContent : '';
|
||||
|
||||
galleryHTML += `
|
||||
<div class="extracted-image-card">
|
||||
<div class="image-wrapper">
|
||||
<img src="/results/pictures/${imgSrc}" alt="Extracted Image ${pictureId}"
|
||||
onclick="openImageModal('/results/pictures/${imgSrc}', '${captionText}', '${coordsText}')">
|
||||
</div>
|
||||
<div class="image-info">
|
||||
<h4>Picture ${pictureId}</h4>
|
||||
${captionText ? `<p class="image-caption">${captionText}</p>` : ''}
|
||||
<p class="image-coords">${coordsText}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
imageGallery.innerHTML = galleryHTML;
|
||||
ui.show('extracted-images-container');
|
||||
appState.generatedOutputs.extractor = true;
|
||||
} catch (error) {
|
||||
console.log('No extracted images found:', error);
|
||||
imageGallery.innerHTML = '<div class="no-images">No images have been extracted yet. Run the image extraction first.</div>';
|
||||
appState.generatedOutputs.extractor = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal Functions
|
||||
function openImageModal(imageSrc, caption, coords) {
|
||||
let modal = document.getElementById('image-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'image-modal';
|
||||
modal.className = 'image-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<span class="modal-close" onclick="closeImageModal()">×</span>
|
||||
<img id="modal-image" src="" alt="Extracted Image">
|
||||
<div class="modal-info">
|
||||
<p id="modal-caption"></p>
|
||||
<p id="modal-coords"></p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
document.getElementById('modal-image').src = imageSrc;
|
||||
ui.setText('modal-caption', caption);
|
||||
ui.setText('modal-coords', coords);
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeImageModal() {
|
||||
const modal = document.getElementById('image-modal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
}
|
||||
|
||||
// Environment Check
|
||||
async function checkEnvironment() {
|
||||
const envDiv = document.getElementById('environment-check');
|
||||
const envDetails = document.getElementById('env-details');
|
||||
|
||||
ui.show('environment-check');
|
||||
ui.setHtml('env-details', '<div class="loader"></div> Checking environment...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/check-environment');
|
||||
const data = await response.json();
|
||||
|
||||
let html = '<ul>';
|
||||
html += `<li>Working directory: <code>${data.cwd}</code></li>`;
|
||||
html += `<li>Python version: <code>${data.python_version}</code></li>`;
|
||||
|
||||
if (data.missing_scripts.length === 0) {
|
||||
html += '<li class="env-success">✓ All required scripts found</li>';
|
||||
} else {
|
||||
html += `<li class="env-error">✗ Missing scripts: <code>${data.missing_scripts.join(', ')}</code></li>`;
|
||||
}
|
||||
|
||||
if (data.pdf_files.length > 0) {
|
||||
html += `<li class="env-success">✓ Found ${data.pdf_files.length} PDF files</li>`;
|
||||
} else {
|
||||
html += '<li class="env-error">✗ No PDF files found in the working directory</li>';
|
||||
}
|
||||
|
||||
html += '</ul>';
|
||||
ui.setHtml('env-details', html);
|
||||
} catch (error) {
|
||||
ui.setHtml('env-details', `<div class="env-error">Error checking environment: ${error.message}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Command Execution
|
||||
async function manuallyRunScript() {
|
||||
const command = prompt("Enter command to run:");
|
||||
if (!command) return;
|
||||
|
||||
const outputDiv = document.getElementById('output');
|
||||
ui.setText('output', 'Running command: ' + command + '\nPlease wait...');
|
||||
ui.show('output');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('command', command);
|
||||
|
||||
try {
|
||||
const response = await fetch('/run-manual-command', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
ui.setText('output',
|
||||
'Command: ' + command + '\n\n' +
|
||||
(data.success ? 'Success!\n\n' : 'Failed!\n\n') +
|
||||
(data.output || '') +
|
||||
(data.error ? '\n\nError: ' + data.error : '')
|
||||
);
|
||||
} catch (error) {
|
||||
ui.setText('output', 'Error running command: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Application
|
||||
window.addEventListener('DOMContentLoaded', async function() {
|
||||
// Load PDF files
|
||||
const pdfStatus = document.getElementById('pdf-load-status');
|
||||
ui.setHtml('pdf-load-status', '<div class="loader"></div> Loading PDF files...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/pdf-files');
|
||||
const data = await response.json();
|
||||
const select = document.getElementById('pdf_file');
|
||||
|
||||
if (data.length === 0) {
|
||||
ui.setHtml('pdf-load-status', '<span class="error">No PDF files found</span>');
|
||||
return;
|
||||
}
|
||||
|
||||
data.forEach(file => {
|
||||
const option = document.createElement('option');
|
||||
option.value = file;
|
||||
option.textContent = file;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
ui.setHtml('pdf-load-status', `<span class="success">Loaded ${data.length} PDF files</span>`);
|
||||
} catch (error) {
|
||||
ui.setHtml('pdf-load-status', '<span class="error">Error: ' + error.message + '</span>');
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('pdf_file').addEventListener('change', loadPDFPreview);
|
||||
document.getElementById('page_num').addEventListener('change', loadPDFPreview);
|
||||
|
||||
const previewPageInput = document.getElementById('preview-page-input');
|
||||
if (previewPageInput) {
|
||||
previewPageInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') goToPreviewPage();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial checks
|
||||
checkEnvironment();
|
||||
loadExtractedImages();
|
||||
|
||||
// Check for existing visualization
|
||||
try {
|
||||
const response = await fetch('/results/visualization_page_1.png');
|
||||
if (response.ok) {
|
||||
appState.generatedOutputs.visualizer = '/results/visualization_page_1.png';
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
|
@ -1,718 +0,0 @@
|
|||
// Batch Processor State Management
|
||||
const batchState = {
|
||||
isProcessing: false,
|
||||
isPaused: false,
|
||||
currentBatchId: null,
|
||||
totalPages: 0,
|
||||
processedPages: 0,
|
||||
startTime: null,
|
||||
pageStatuses: {},
|
||||
results: {
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
totalImages: 0,
|
||||
failedPages: []
|
||||
},
|
||||
autoScroll: true
|
||||
};
|
||||
|
||||
// Timer for elapsed time
|
||||
let elapsedTimer = null;
|
||||
|
||||
// Initialize on page load
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
loadPDFFiles();
|
||||
setupEventListeners();
|
||||
checkBatchEnvironment();
|
||||
});
|
||||
|
||||
// Load available PDF files
|
||||
function loadPDFFiles() {
|
||||
fetch('/pdf-files')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const select = document.getElementById('batch_pdf_file');
|
||||
data.forEach(file => {
|
||||
const option = document.createElement('option');
|
||||
option.value = file;
|
||||
option.textContent = file;
|
||||
select.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading PDFs:', error);
|
||||
addConsoleMessage('Error loading PDF files: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
function setupEventListeners() {
|
||||
// PDF selection change
|
||||
document.getElementById('batch_pdf_file').addEventListener('change', function() {
|
||||
if (this.value) {
|
||||
fetchPDFInfo(this.value);
|
||||
} else {
|
||||
document.getElementById('batch-pdf-info').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Page range radio buttons
|
||||
document.querySelectorAll('input[name="page_range"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
const customRange = document.getElementById('custom-range');
|
||||
if (this.value === 'range') {
|
||||
customRange.classList.remove('hidden');
|
||||
} else {
|
||||
customRange.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch PDF information (page count)
|
||||
function fetchPDFInfo(pdfFile) {
|
||||
fetch(`/pdf-info/${encodeURIComponent(pdfFile)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('total-pages').textContent = data.pageCount;
|
||||
document.getElementById('batch-pdf-info').classList.remove('hidden');
|
||||
|
||||
// Update max values for custom range
|
||||
document.getElementById('start_page').max = data.pageCount;
|
||||
document.getElementById('end_page').max = data.pageCount;
|
||||
document.getElementById('end_page').value = data.pageCount;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching PDF info:', error);
|
||||
addConsoleMessage('Error fetching PDF info: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Start batch processing
|
||||
function startBatchProcessing() {
|
||||
const pdfFile = document.getElementById('batch_pdf_file').value;
|
||||
if (!pdfFile) {
|
||||
alert('Please select a PDF file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get page range
|
||||
const pageRangeType = document.querySelector('input[name="page_range"]:checked').value;
|
||||
let startPage = 1;
|
||||
let endPage = parseInt(document.getElementById('total-pages').textContent);
|
||||
|
||||
if (pageRangeType === 'range') {
|
||||
startPage = parseInt(document.getElementById('start_page').value) || 1;
|
||||
endPage = parseInt(document.getElementById('end_page').value) || endPage;
|
||||
|
||||
if (startPage > endPage) {
|
||||
alert('Invalid page range: start page must be less than or equal to end page');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset state
|
||||
resetBatchState();
|
||||
batchState.isProcessing = true;
|
||||
batchState.totalPages = endPage - startPage + 1;
|
||||
batchState.startTime = Date.now();
|
||||
|
||||
// Update UI
|
||||
updateUIForProcessing(true);
|
||||
initializePageGrid(startPage, endPage);
|
||||
|
||||
// Start elapsed timer
|
||||
startElapsedTimer();
|
||||
|
||||
// Show console
|
||||
document.getElementById('console-output').classList.remove('hidden');
|
||||
addConsoleMessage(`Starting batch processing for ${pdfFile} (pages ${startPage}-${endPage})`, 'info');
|
||||
|
||||
// Prepare request data
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_file', pdfFile);
|
||||
formData.append('start_page', startPage);
|
||||
formData.append('end_page', endPage);
|
||||
formData.append('adjust', document.getElementById('batch_adjust').checked);
|
||||
formData.append('parallel', document.getElementById('parallel_processing').checked);
|
||||
formData.append('generate_report', document.getElementById('generate_report').checked);
|
||||
|
||||
// Start batch processing
|
||||
fetch('/run-batch-processor', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
batchState.currentBatchId = data.batch_id;
|
||||
addConsoleMessage(`Batch processing started with ID: ${data.batch_id}`, 'success');
|
||||
|
||||
// Start polling for updates
|
||||
pollBatchStatus();
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to start batch processing');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error starting batch processing:', error);
|
||||
addConsoleMessage('Error: ' + error.message, 'error');
|
||||
finishBatchProcessing(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Poll for batch status updates
|
||||
function pollBatchStatus() {
|
||||
if (!batchState.isProcessing || !batchState.currentBatchId) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/batch-status/${batchState.currentBatchId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateBatchProgress(data);
|
||||
|
||||
if (data.completed) {
|
||||
finishBatchProcessing(true);
|
||||
} else if (!batchState.isPaused) {
|
||||
// Continue polling
|
||||
setTimeout(pollBatchStatus, 1000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error polling batch status:', error);
|
||||
if (batchState.isProcessing) {
|
||||
setTimeout(pollBatchStatus, 2000); // Retry with longer delay
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update batch progress
|
||||
function updateBatchProgress(data) {
|
||||
// Update overall progress
|
||||
const percentage = Math.round((data.processed / data.total) * 100);
|
||||
document.getElementById('overall-percentage').textContent = percentage + '%';
|
||||
document.getElementById('overall-progress-fill').style.width = percentage + '%';
|
||||
document.getElementById('pages-completed').textContent = data.processed;
|
||||
document.getElementById('pages-total').textContent = data.total;
|
||||
|
||||
// Update stage progress
|
||||
if (data.stages) {
|
||||
updateStageProgress('analysis', data.stages.analysis);
|
||||
updateStageProgress('visualization', data.stages.visualization);
|
||||
updateStageProgress('extraction', data.stages.extraction);
|
||||
}
|
||||
|
||||
// Update page statuses
|
||||
if (data.page_statuses) {
|
||||
for (const [pageNum, status] of Object.entries(data.page_statuses)) {
|
||||
updatePageStatus(pageNum, status);
|
||||
}
|
||||
}
|
||||
|
||||
// Update results
|
||||
if (data.results) {
|
||||
batchState.results = data.results;
|
||||
updateResultsSummary();
|
||||
}
|
||||
|
||||
// Add log messages
|
||||
if (data.logs && data.logs.length > 0) {
|
||||
data.logs.forEach(log => {
|
||||
addConsoleMessage(log.message, log.level);
|
||||
});
|
||||
}
|
||||
|
||||
// Update ETA
|
||||
if (data.eta) {
|
||||
document.getElementById('eta-time').textContent = formatTime(data.eta);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stage progress
|
||||
function updateStageProgress(stage, data) {
|
||||
if (!data) return;
|
||||
|
||||
const percentage = Math.round((data.completed / data.total) * 100);
|
||||
document.getElementById(`${stage}-percentage`).textContent = percentage + '%';
|
||||
document.getElementById(`${stage}-progress`).style.width = percentage + '%';
|
||||
}
|
||||
|
||||
// Initialize page grid
|
||||
function initializePageGrid(startPage, endPage) {
|
||||
const grid = document.getElementById('page-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
document.getElementById('batch-progress-panel').classList.remove('hidden');
|
||||
document.getElementById('page-status-grid').classList.remove('hidden');
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const pageItem = createPageStatusItem(i);
|
||||
grid.appendChild(pageItem);
|
||||
batchState.pageStatuses[i] = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
// Create page status item
|
||||
function createPageStatusItem(pageNum) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'page-status-item pending';
|
||||
item.id = `page-status-${pageNum}`;
|
||||
item.innerHTML = `
|
||||
<div class="page-number">Page ${pageNum}</div>
|
||||
<div class="page-status-icon">⏳</div>
|
||||
<div class="page-actions hidden">
|
||||
<button class="mini-btn" onclick="viewPageResults(${pageNum})" title="View results">
|
||||
👁️
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
item.onclick = () => viewPageResults(pageNum);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Update page status
|
||||
function updatePageStatus(pageNum, status) {
|
||||
const item = document.getElementById(`page-status-${pageNum}`);
|
||||
if (!item) return;
|
||||
|
||||
// Remove all status classes
|
||||
item.classList.remove('pending', 'processing', 'completed', 'failed');
|
||||
|
||||
// Add new status class
|
||||
item.classList.add(status.toLowerCase());
|
||||
|
||||
// Update icon
|
||||
const icons = {
|
||||
'pending': '⏳',
|
||||
'processing': '🔄',
|
||||
'completed': '✅',
|
||||
'failed': '❌'
|
||||
};
|
||||
|
||||
const iconElement = item.querySelector('.page-status-icon');
|
||||
iconElement.textContent = icons[status.toLowerCase()] || '❓';
|
||||
|
||||
// Show actions for completed pages
|
||||
if (status.toLowerCase() === 'completed') {
|
||||
item.querySelector('.page-actions').classList.remove('hidden');
|
||||
}
|
||||
|
||||
batchState.pageStatuses[pageNum] = status.toLowerCase();
|
||||
}
|
||||
|
||||
// View page results
|
||||
function viewPageResults(pageNum) {
|
||||
// Check if we have a current batch ID
|
||||
if (!batchState.currentBatchId) {
|
||||
addConsoleMessage('No batch ID available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Open modal with page visualization and extracted images
|
||||
const modal = document.getElementById('batch-image-modal');
|
||||
modal.classList.add('active');
|
||||
|
||||
// Load visualization from the batch report directory
|
||||
const vizImage = document.getElementById('modal-visualization-image');
|
||||
vizImage.src = `/batch-report-image/${batchState.currentBatchId}/visualization_page_${pageNum}.png?t=${Date.now()}`;
|
||||
|
||||
// Handle loading errors
|
||||
vizImage.onerror = function() {
|
||||
// Try the regular results directory as fallback
|
||||
this.src = `/results/visualization_page_${pageNum}.png?t=${Date.now()}`;
|
||||
|
||||
this.onerror = function() {
|
||||
this.alt = 'Visualization not found';
|
||||
console.error('Failed to load visualization');
|
||||
};
|
||||
};
|
||||
|
||||
// Load extracted images
|
||||
loadExtractedImagesForPage(pageNum);
|
||||
|
||||
// Update page info
|
||||
document.getElementById('modal-page-info').textContent = `Page ${pageNum} Results`;
|
||||
}
|
||||
|
||||
// Load extracted images for a specific page
|
||||
function loadExtractedImagesForPage(pageNum) {
|
||||
const gallery = document.getElementById('modal-extracted-gallery');
|
||||
gallery.innerHTML = '<div class="loader"></div> Loading extracted images...';
|
||||
|
||||
// Fetch extracted images for this page
|
||||
fetch(`/results/pictures_page_${pageNum}/index.html`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('No extracted images');
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
// Parse and display images
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const images = doc.querySelectorAll('.picture-card img');
|
||||
|
||||
if (images.length === 0) {
|
||||
gallery.innerHTML = '<p class="no-images">No images extracted from this page</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let galleryHTML = '';
|
||||
images.forEach(img => {
|
||||
const src = img.getAttribute('src');
|
||||
galleryHTML += `
|
||||
<div class="modal-gallery-item">
|
||||
<img src="/results/pictures_page_${pageNum}/${src}"
|
||||
alt="Extracted image"
|
||||
onclick="window.open('/results/pictures_page_${pageNum}/${src}', '_blank')">
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
gallery.innerHTML = galleryHTML;
|
||||
})
|
||||
.catch(error => {
|
||||
gallery.innerHTML = '<p class="no-images">No extracted images available</p>';
|
||||
});
|
||||
}
|
||||
|
||||
// Switch modal tabs
|
||||
function switchModalTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.modal-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Update content
|
||||
document.querySelectorAll('.modal-tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`modal-${tabName}-content`).classList.add('active');
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeBatchImageModal() {
|
||||
document.getElementById('batch-image-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
// Pause batch processing
|
||||
function pauseBatchProcessing() {
|
||||
if (!batchState.isProcessing || batchState.isPaused) return;
|
||||
|
||||
batchState.isPaused = true;
|
||||
|
||||
fetch(`/pause-batch/${batchState.currentBatchId}`, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
addConsoleMessage('Batch processing paused', 'info');
|
||||
document.getElementById('pause-batch-btn').classList.add('hidden');
|
||||
document.getElementById('resume-batch-btn').classList.remove('hidden');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error pausing batch:', error);
|
||||
addConsoleMessage('Error pausing batch: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Resume batch processing
|
||||
function resumeBatchProcessing() {
|
||||
if (!batchState.isProcessing || !batchState.isPaused) return;
|
||||
|
||||
batchState.isPaused = false;
|
||||
|
||||
fetch(`/resume-batch/${batchState.currentBatchId}`, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
addConsoleMessage('Batch processing resumed', 'info');
|
||||
document.getElementById('resume-batch-btn').classList.add('hidden');
|
||||
document.getElementById('pause-batch-btn').classList.remove('hidden');
|
||||
|
||||
// Resume polling
|
||||
pollBatchStatus();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error resuming batch:', error);
|
||||
addConsoleMessage('Error resuming batch: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel batch processing
|
||||
function cancelBatchProcessing() {
|
||||
if (!batchState.isProcessing) return;
|
||||
|
||||
if (!confirm('Are you sure you want to cancel the batch processing?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/cancel-batch/${batchState.currentBatchId}`, { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
addConsoleMessage('Batch processing cancelled', 'warning');
|
||||
finishBatchProcessing(false);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error cancelling batch:', error);
|
||||
addConsoleMessage('Error cancelling batch: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Finish batch processing
|
||||
function finishBatchProcessing(success) {
|
||||
batchState.isProcessing = false;
|
||||
stopElapsedTimer();
|
||||
|
||||
// Update UI
|
||||
updateUIForProcessing(false);
|
||||
|
||||
// Show results
|
||||
document.getElementById('batch-results').classList.remove('hidden');
|
||||
updateResultsSummary();
|
||||
|
||||
if (success) {
|
||||
addConsoleMessage('Batch processing completed successfully!', 'success');
|
||||
} else {
|
||||
addConsoleMessage('Batch processing stopped', 'warning');
|
||||
}
|
||||
|
||||
// Update failed pages list if any
|
||||
if (batchState.results.failedPages.length > 0) {
|
||||
showFailedPages();
|
||||
}
|
||||
}
|
||||
|
||||
// Update results summary
|
||||
function updateResultsSummary() {
|
||||
document.getElementById('stat-success').textContent = batchState.results.successful;
|
||||
document.getElementById('stat-failed').textContent = batchState.results.failed;
|
||||
document.getElementById('stat-duration').textContent = formatTime(Date.now() - batchState.startTime);
|
||||
document.getElementById('stat-images').textContent = batchState.results.totalImages;
|
||||
}
|
||||
|
||||
// Show failed pages
|
||||
function showFailedPages() {
|
||||
const failedSection = document.getElementById('failed-pages');
|
||||
const failedList = document.getElementById('failed-list');
|
||||
|
||||
failedSection.classList.remove('hidden');
|
||||
failedList.innerHTML = '';
|
||||
|
||||
batchState.results.failedPages.forEach(page => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'failed-item';
|
||||
item.innerHTML = `
|
||||
<span>Page ${page.pageNum}</span>
|
||||
<span class="failed-reason">${page.reason}</span>
|
||||
<button class="retry-btn" onclick="retryPage(${page.pageNum})">Retry</button>
|
||||
`;
|
||||
failedList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// Retry failed page
|
||||
function retryPage(pageNum) {
|
||||
addConsoleMessage(`Retrying page ${pageNum}...`, 'info');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_file', document.getElementById('batch_pdf_file').value);
|
||||
formData.append('page_num', pageNum);
|
||||
formData.append('adjust', document.getElementById('batch_adjust').checked);
|
||||
|
||||
// Update page status
|
||||
updatePageStatus(pageNum, 'processing');
|
||||
|
||||
fetch('/retry-page', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
updatePageStatus(pageNum, 'completed');
|
||||
addConsoleMessage(`Page ${pageNum} processed successfully`, 'success');
|
||||
|
||||
// Update results
|
||||
batchState.results.successful++;
|
||||
batchState.results.failed--;
|
||||
batchState.results.failedPages = batchState.results.failedPages.filter(p => p.pageNum !== pageNum);
|
||||
updateResultsSummary();
|
||||
|
||||
if (batchState.results.failedPages.length === 0) {
|
||||
document.getElementById('failed-pages').classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
updatePageStatus(pageNum, 'failed');
|
||||
addConsoleMessage(`Failed to process page ${pageNum}: ${data.error}`, 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
updatePageStatus(pageNum, 'failed');
|
||||
addConsoleMessage(`Error retrying page ${pageNum}: ${error.message}`, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Download all results
|
||||
function downloadResults() {
|
||||
addConsoleMessage('Preparing results for download...', 'info');
|
||||
|
||||
fetch(`/download-batch-results/${batchState.currentBatchId}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to prepare download');
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `doctags_batch_results_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
addConsoleMessage('Download started', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
addConsoleMessage('Error downloading results: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// View detailed report
|
||||
function viewReport() {
|
||||
window.open(`/batch-report/${batchState.currentBatchId}`, '_blank');
|
||||
}
|
||||
|
||||
// Open results folder
|
||||
function openResultsFolder() {
|
||||
addConsoleMessage('Opening results folder...', 'info');
|
||||
|
||||
fetch('/open-results-folder', { method: 'POST' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
addConsoleMessage('Results folder opened', 'success');
|
||||
} else {
|
||||
addConsoleMessage('Could not open results folder: ' + data.error, 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
addConsoleMessage('Error: ' + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Console functions
|
||||
function addConsoleMessage(message, level = 'info') {
|
||||
const console = document.getElementById('console-content');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `console-message ${level}`;
|
||||
messageDiv.innerHTML = `<span class="timestamp">[${timestamp}]</span> ${message}`;
|
||||
|
||||
console.appendChild(messageDiv);
|
||||
|
||||
if (batchState.autoScroll) {
|
||||
console.scrollTop = console.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function clearConsole() {
|
||||
document.getElementById('console-content').innerHTML = '';
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
batchState.autoScroll = !batchState.autoScroll;
|
||||
document.getElementById('autoscroll-status').textContent =
|
||||
`Auto-scroll: ${batchState.autoScroll ? 'ON' : 'OFF'}`;
|
||||
}
|
||||
|
||||
// Timer functions
|
||||
function startElapsedTimer() {
|
||||
elapsedTimer = setInterval(() => {
|
||||
const elapsed = Date.now() - batchState.startTime;
|
||||
document.getElementById('elapsed-time').textContent = formatTime(elapsed);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopElapsedTimer() {
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer);
|
||||
elapsedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatTime(milliseconds) {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetBatchState() {
|
||||
batchState.isProcessing = false;
|
||||
batchState.isPaused = false;
|
||||
batchState.currentBatchId = null;
|
||||
batchState.totalPages = 0;
|
||||
batchState.processedPages = 0;
|
||||
batchState.startTime = null;
|
||||
batchState.pageStatuses = {};
|
||||
batchState.results = {
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
totalImages: 0,
|
||||
failedPages: []
|
||||
};
|
||||
}
|
||||
|
||||
function updateUIForProcessing(isProcessing) {
|
||||
// Toggle button visibility
|
||||
document.getElementById('start-batch-btn').classList.toggle('hidden', isProcessing);
|
||||
document.getElementById('pause-batch-btn').classList.toggle('hidden', !isProcessing);
|
||||
document.getElementById('cancel-batch-btn').classList.toggle('hidden', !isProcessing);
|
||||
|
||||
// Disable form inputs during processing
|
||||
const inputs = document.querySelectorAll('.settings-panel input, .settings-panel select');
|
||||
inputs.forEach(input => {
|
||||
input.disabled = isProcessing;
|
||||
});
|
||||
}
|
||||
|
||||
// Check batch environment
|
||||
function checkBatchEnvironment() {
|
||||
fetch('/check-environment')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const envDiv = document.getElementById('batch-environment-check');
|
||||
const envDetails = document.getElementById('batch-env-details');
|
||||
|
||||
if (data.missing_scripts.length > 0 || data.pdf_files.length === 0) {
|
||||
envDiv.classList.remove('hidden');
|
||||
|
||||
let html = '<ul>';
|
||||
if (data.missing_scripts.length > 0) {
|
||||
html += '<li class="env-error">Missing scripts: ' + data.missing_scripts.join(', ') + '</li>';
|
||||
}
|
||||
if (data.pdf_files.length === 0) {
|
||||
html += '<li class="env-error">No PDF files found in working directory</li>';
|
||||
}
|
||||
html += '</ul>';
|
||||
|
||||
envDetails.innerHTML = html;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking environment:', error);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,616 +0,0 @@
|
|||
/* Batch Processor Specific Styles */
|
||||
|
||||
/* Navigation link */
|
||||
.nav-link {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.nav-link a {
|
||||
color: var(--primary-medium);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link a:hover {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Batch Settings Grid */
|
||||
.batch-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.pdf-info {
|
||||
margin-top: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Page Range Controls */
|
||||
.page-range-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
color: var(--text-medium);
|
||||
}
|
||||
|
||||
.radio-label input[type="radio"] {
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.custom-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.custom-range input {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
/* Processing Options */
|
||||
.processing-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Batch Progress Panel */
|
||||
.batch-progress-panel {
|
||||
background: var(--surface-white);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-xl) 0;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* Overall Progress */
|
||||
.overall-progress {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.progress-header span:first-child {
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
#overall-percentage {
|
||||
font-weight: 600;
|
||||
color: var(--primary-medium);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 24px;
|
||||
background: var(--surface-light);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.progress-bar.small {
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-medium), var(--primary-dark));
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.2) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
rgba(255, 255, 255, 0.2) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size: 30px 30px;
|
||||
animation: progress-stripes 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-stripes {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 30px 0; }
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--border-light);
|
||||
}
|
||||
|
||||
/* Stage Progress */
|
||||
.stage-progress {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stage-item {
|
||||
padding: var(--spacing-md);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.stage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stage-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stage-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.stage-percentage {
|
||||
font-weight: 600;
|
||||
color: var(--text-medium);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Stage-specific progress colors */
|
||||
.progress-fill.analysis {
|
||||
background: linear-gradient(90deg, #3498db, #2980b9);
|
||||
}
|
||||
|
||||
.progress-fill.visualization {
|
||||
background: linear-gradient(90deg, #e74c3c, #c0392b);
|
||||
}
|
||||
|
||||
.progress-fill.extraction {
|
||||
background: linear-gradient(90deg, #2ecc71, #27ae60);
|
||||
}
|
||||
|
||||
/* Batch Controls */
|
||||
.batch-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
margin: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--primary-medium);
|
||||
color: white;
|
||||
padding: var(--spacing-md) var(--spacing-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--accent-amber);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: #f0c674;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
/* Page Status Grid */
|
||||
.page-status-grid {
|
||||
background: var(--surface-white);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-xl) 0;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.page-status-item {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-status-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.page-status-item.pending {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.page-status-item.processing {
|
||||
border-color: var(--accent-amber);
|
||||
background: linear-gradient(135deg, var(--accent-amber) 0%, transparent 100%);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.page-status-item.completed {
|
||||
border-color: var(--success);
|
||||
background: var(--accent-mint);
|
||||
}
|
||||
|
||||
.page-status-item.failed {
|
||||
border-color: var(--error);
|
||||
background: var(--accent-rose);
|
||||
}
|
||||
|
||||
.page-number {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.page-status-icon {
|
||||
font-size: 1.5rem;
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.mini-btn {
|
||||
padding: 2px 4px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--surface-white);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Batch Results */
|
||||
.batch-results {
|
||||
background: var(--surface-white);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-xl) 0;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
margin: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--primary-medium);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Failed Pages */
|
||||
.failed-pages {
|
||||
margin-top: var(--spacing-xl);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--accent-rose);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
.failed-pages h4 {
|
||||
color: var(--error);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.failed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.failed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--surface-white);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.failed-reason {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-medium);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: var(--warning);
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Console Output */
|
||||
.console-output {
|
||||
background: var(--text-dark);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-xl) 0;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.console-header h4 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.console-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.console-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.console-content {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.console-message {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.console-message.info {
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
.console-message.success {
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.console-message.warning {
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.console-message.error {
|
||||
color: #e57373;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #90a4ae;
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Modal Enhancements */
|
||||
.modal-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: var(--surface-light);
|
||||
}
|
||||
|
||||
.modal-tab {
|
||||
flex: 1;
|
||||
padding: var(--spacing-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--text-medium);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-tab.active {
|
||||
color: var(--primary-dark);
|
||||
border-bottom: 2px solid var(--primary-medium);
|
||||
}
|
||||
|
||||
.modal-tab-content {
|
||||
display: none;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-gallery-item {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-gallery-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal-gallery-item img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.batch-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stage-progress {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
}
|
||||
|
||||
.results-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.batch-controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.console-content {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,970 +0,0 @@
|
|||
/* Professional Clean Design for DocTags Tools */
|
||||
|
||||
:root {
|
||||
/* Exact color palette */
|
||||
--primary-bg: #f8f1ee;
|
||||
--surface-white: #ffffff;
|
||||
--surface-light: #f5f7fa;
|
||||
--primary-dark: #195162;
|
||||
--primary-medium: #618794;
|
||||
--accent-mint: #b9e9da;
|
||||
--accent-amber: #ffdb96;
|
||||
--accent-rose: #ffc9c9;
|
||||
--text-dark: #2c3e50;
|
||||
--text-medium: #546e7a;
|
||||
--text-light: #78909c;
|
||||
--border-light: #e1e8ed;
|
||||
--success: #27ae60;
|
||||
--error: #e74c3c;
|
||||
--warning: #f39c12;
|
||||
|
||||
/* Design system */
|
||||
--shadow-sm: 0 1px 3px rgba(25, 81, 98, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(25, 81, 98, 0.10);
|
||||
--shadow-lg: 0 8px 24px rgba(25, 81, 98, 0.12);
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--spacing-xs: 8px;
|
||||
--spacing-sm: 12px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
}
|
||||
/* Professional Enterprise Title Styles - Add this after the :root variables in styles.css */
|
||||
|
||||
/* Enterprise Style Title */
|
||||
.title-enterprise {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 48px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.title-enterprise .brand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--surface-white);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-enterprise .brand-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(45deg, var(--accent-mint) 0%, transparent 70%);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.title-enterprise .brand-icon svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
fill: var(--primary-dark);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.title-enterprise .brand-text {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.title-enterprise h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark);
|
||||
margin: 0 0 4px 0;
|
||||
letter-spacing: -0.025em;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.title-enterprise .pro-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
background: var(--accent-amber);
|
||||
color: var(--primary-dark);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.title-enterprise .tagline {
|
||||
font-size: 1rem;
|
||||
color: var(--text-medium);
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Update the old h1 styles to remove conflicts */
|
||||
body > .container > h1 {
|
||||
display: none; /* Hide if there's still an old h1 */
|
||||
}
|
||||
|
||||
/* Responsive adjustments for the title */
|
||||
@media (max-width: 768px) {
|
||||
.title-enterprise {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.title-enterprise .brand-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title-enterprise h1 {
|
||||
font-size: 1.75rem;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-enterprise .brand-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.title-enterprise .brand-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.title-enterprise .tagline {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--primary-bg);
|
||||
color: var(--text-dark);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: var(--spacing-lg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
color: var(--primary-dark);
|
||||
text-align: center;
|
||||
margin: 0 0 var(--spacing-xl) 0;
|
||||
font-size: 2.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
color: var(--primary-dark);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-medium);
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Settings Panel */
|
||||
.settings-panel {
|
||||
background: var(--surface-white);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px 200px;
|
||||
gap: var(--spacing-lg);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Specific styling for PDF form group */
|
||||
.pdf-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* CSS for aligning PDF Document label and status on same line */
|
||||
.pdf-group > div:first-child {
|
||||
display: flex !important;
|
||||
justify-content: space-between !important;
|
||||
align-items: center !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.pdf-group > div:first-child label {
|
||||
margin: 0 !important;
|
||||
font-weight: 500 !important;
|
||||
color: var(--primary-dark) !important;
|
||||
font-size: 0.875rem !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.025em !important;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: var(--primary-dark);
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
select, input[type="number"] {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-white);
|
||||
color: var(--text-dark);
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
select:focus, input:focus {
|
||||
border-color: var(--primary-medium);
|
||||
box-shadow: 0 0 0 3px rgba(97, 135, 148, 0.1);
|
||||
}
|
||||
|
||||
/* PDF Load Status - positioned below the select but within the form group */
|
||||
#pdf-load-status {
|
||||
margin: 0 !important;
|
||||
font-size: 0.8rem !important;
|
||||
font-weight: 500 !important;
|
||||
color: #27ae60 !important;
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary-medium);
|
||||
margin-right: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label:has(input[type="checkbox"]) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
text-transform: none;
|
||||
font-weight: 400;
|
||||
color: var(--text-medium);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Progress Indicator */
|
||||
.progress-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: var(--spacing-xl) 0;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-white);
|
||||
color: var(--text-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.step-indicator.completed {
|
||||
background: var(--primary-medium);
|
||||
color: white;
|
||||
border-color: var(--primary-medium);
|
||||
}
|
||||
|
||||
.step-indicator.active {
|
||||
background: var(--accent-amber);
|
||||
color: var(--primary-dark);
|
||||
border-color: var(--accent-amber);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
width: 60px;
|
||||
height: 2px;
|
||||
background: var(--border-light);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.step-connector.completed {
|
||||
background: var(--primary-medium);
|
||||
}
|
||||
|
||||
/* Tabs Container */
|
||||
.tabs-container {
|
||||
background: var(--surface-white);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
background: var(--surface-light);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-medium);
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background: var(--surface-white);
|
||||
color: var(--primary-medium);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: var(--surface-white);
|
||||
color: var(--primary-dark);
|
||||
border-bottom-color: var(--primary-medium);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
padding: var(--spacing-xl);
|
||||
background: var(--surface-white);
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--primary-medium);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--primary-dark);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: var(--border-light);
|
||||
color: var(--text-light);
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status {
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border-left: 3px solid;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: var(--accent-mint);
|
||||
color: var(--primary-dark);
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--accent-rose);
|
||||
color: var(--error);
|
||||
border-left-color: var(--error);
|
||||
}
|
||||
|
||||
.working {
|
||||
background: var(--accent-amber);
|
||||
color: var(--primary-dark);
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
|
||||
/* Loader animation */
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top: 2px solid var(--primary-medium);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Tab descriptions */
|
||||
.tab-description {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid var(--primary-medium);
|
||||
}
|
||||
|
||||
.tab-description strong {
|
||||
color: var(--primary-dark);
|
||||
font-size: 0.9rem;
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tab-description ul {
|
||||
margin: var(--spacing-sm) 0 0 0;
|
||||
padding-left: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.tab-description li {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
color: var(--text-medium);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tab-description code {
|
||||
background: var(--surface-white);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--primary-dark);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* Output section */
|
||||
.output {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--text-dark);
|
||||
color: #e2e8f0;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Image container (for visualizer) */
|
||||
#image-container {
|
||||
margin-top: var(--spacing-lg);
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
#image-container h4 {
|
||||
color: var(--primary-dark);
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#result-image {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* PDF Preview container */
|
||||
#pdf-preview-container {
|
||||
margin-top: var(--spacing-lg);
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
#pdf-preview-container h4 {
|
||||
color: var(--primary-dark);
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Preview Controls */
|
||||
.preview-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--surface-white);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--primary-medium);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.page-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.page-selector label {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: var(--text-medium);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.page-input {
|
||||
width: 60px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.go-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: var(--accent-mint);
|
||||
color: var(--primary-dark);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.go-btn:hover {
|
||||
background: var(--primary-medium);
|
||||
color: white;
|
||||
}
|
||||
|
||||
#pdf-preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 600px;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Extracted Images Gallery */
|
||||
#extracted-images-container {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--surface-light);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
#extracted-images-container h4 {
|
||||
color: var(--primary-dark);
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.images-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.extracted-image-card {
|
||||
background: var(--surface-white);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.extracted-image-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-wrapper img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.image-wrapper:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.image-info {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.image-info h4 {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.image-caption {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-medium);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-coords {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
.no-images {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--text-light);
|
||||
font-style: italic;
|
||||
background: var(--surface-white);
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px dashed var(--border-light);
|
||||
}
|
||||
|
||||
/* Image Modal */
|
||||
.image-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.image-modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--surface-white);
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-lg);
|
||||
color: var(--text-light);
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
background: var(--surface-white);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--error);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
#modal-image {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
.modal-info {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.modal-info p {
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#modal-caption {
|
||||
color: var(--text-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#modal-coords {
|
||||
color: var(--text-light);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Environment check */
|
||||
#environment-check {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--accent-amber);
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
#environment-check h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.env-success {
|
||||
color: var(--success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.env-error {
|
||||
color: var(--error);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#env-details ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#env-details li {
|
||||
padding: var(--spacing-xs) 0;
|
||||
border-bottom: 1px solid rgba(25, 81, 98, 0.1);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
#env-details li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Debug section */
|
||||
.debug-section {
|
||||
margin-top: var(--spacing-xl);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-light);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.debug-section button {
|
||||
background: var(--text-medium);
|
||||
margin: 0 var(--spacing-xs);
|
||||
font-size: 0.8rem;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.debug-section button:hover {
|
||||
background: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
border-right: none;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
border-bottom: 1px solid var(--primary-medium);
|
||||
border-left: 3px solid var(--primary-medium);
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.images-gallery {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: var(--spacing-md);
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
#modal-image {
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
/* Preview controls responsive */
|
||||
.preview-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
font-size: 0.8rem;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
}
|
||||
|
||||
}
|
||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8081',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
20
run_app.sh
20
run_app.sh
|
|
@ -1,20 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "Starting DocTags Application..."
|
||||
|
||||
# Check if backend directory exists
|
||||
if [ ! -d "backend" ]; then
|
||||
echo "Error: backend directory not found!"
|
||||
echo "Please run the reorganization script first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if app.py exists in backend
|
||||
if [ ! -f "backend/app.py" ]; then
|
||||
echo "Error: backend/app.py not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the Flask application
|
||||
echo "Running Flask application..."
|
||||
python backend/app.py
|
||||
Loading…
Reference in a new issue