Make a nex project base, managing new Docling version
This commit is contained in:
parent
d625ec3cd5
commit
50ba1d43dd
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
|
# Dependencies
|
||||||
document_output
|
node_modules/
|
||||||
results
|
frontend/node_modules/
|
||||||
docs
|
|
||||||
|
# Build outputs
|
||||||
|
frontend/dist/
|
||||||
|
backend/target/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Python
|
||||||
__pycache__/
|
__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 Compose (recommended)
|
||||||
- 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:
|
|
||||||
|
|
||||||
```bash
|
```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**
|
**Document Parser:**
|
||||||
- Check logs: `docker-compose logs analyser`
|
```bash
|
||||||
- Ensure ports aren't in use: `lsof -i :8080`
|
cd document-parser
|
||||||
|
pip install -r requirements.txt
|
||||||
2. **"No module named 'docling_core'" error**
|
uvicorn main:app --reload --port 8000
|
||||||
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 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
|
## Features
|
||||||
2. Rebuild the Docker image: `docker-compose up -d --build`
|
|
||||||
3. Check logs for errors: `docker-compose logs -f analyser`
|
|
||||||
|
|
||||||
## 📄 License
|
- PDF upload and document analysis via Docling
|
||||||
|
- Extracted content viewing (Markdown, HTML)
|
||||||
This project is open source and available under the MIT License.
|
- 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:
|
services:
|
||||||
analyser:
|
postgres:
|
||||||
build: .
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: app
|
||||||
|
POSTGRES_PASSWORD: app
|
||||||
|
POSTGRES_DB: docling_studio
|
||||||
ports:
|
ports:
|
||||||
- "8080:5000"
|
- "5432:5432"
|
||||||
volumes:
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SCUB-Doc Intelligence Suite - Professional Document Analysis</title>
|
<title>Docling Studio</title>
|
||||||
<link rel="stylesheet" href="static/styles.css">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div id="app"></div>
|
||||||
<!-- Professional Enterprise-Style Title -->
|
<script type="module" src="/src/app/main.js"></script>
|
||||||
<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>
|
|
||||||
</body>
|
</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