Merge pull request #5 from scub-france/complete-refacto

Make a new project base, managing new Docling version
This commit is contained in:
Pier-Jean Malandrino 2026-03-17 08:44:08 +01:00 committed by GitHub
commit 21789659b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 4909 additions and 6043 deletions

12
.claude/launch.json Normal file
View file

@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000,
"cwd": "frontend"
}
]
}

View file

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

@ -1,7 +1,28 @@
.idea
document_output
results
docs
# Dependencies
node_modules/
frontend/node_modules/
# Build outputs
frontend/dist/
backend/target/
# IDE
.idea/
.vscode/
*.iml
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.local
# Uploads
uploads/
# Python
__pycache__/
*.pdf
*.pyc
.venv/

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

View file

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

@ -1,111 +1,51 @@
# DocTags Analyzer and Visualizer
# Docling Studio
AI-powered document analysis and visualization tool for extracting structured content from PDFs.
A professional document analysis studio powered by [Docling](https://github.com/DS4SD/docling), inspired by MistralAI Studio.
<img width="800" height="685" alt="image" src="https://github.com/user-attachments/assets/ce301175-ea4b-42e8-8e3a-ff9c326b84cd" />
## Architecture
```
frontend/ → Vue 3 + Vite + Pinia (port 3000)
backend/ → Spring Boot 3.3.5 / Java 21 (port 8081)
document-parser/ → FastAPI + Docling (port 8000)
```
## 🚀 Quick Start with Docker
## Quick Start
### Prerequisites
- Docker and Docker Compose installed
- At least 4GB of free memory
- ~500MB disk space for the AI model
### Running with Docker
1. **Clone the repository**
```bash
git clone <repository-url>
cd SmolDocling-visualizer
```
2. **Place your PDF files in the project directory**
```bash
cp /path/to/your/document.pdf ./
```
3. **Start the application**
```bash
docker-compose up -d --build
```
4. **Access the web interface**
- Open http://localhost:8080 in your browser
- Select a PDF from the dropdown
- Process your documents through the three-step workflow
### First Run Notice
⚠️ **Important**: The first analysis will take 5-10 minutes as the AI model (SmolDocling-256M) needs to be downloaded (~500MB). Subsequent runs will be much faster (30-60 seconds).
## 📋 Features
- **Document Analysis**: Extract comprehensive document structure using AI
- **Visualization**: Generate visual overlays showing document elements
- **Image Extraction**: Automatically extract and catalog embedded images
- **Web Interface**: User-friendly interface for document processing
## 🛠️ Manual Usage
Process PDF pages with DocTags:
### Docker Compose (recommended)
```bash
python analyzer.py --image document.pdf --page 8 && python visualizer.py --doctags results/output.doctags.txt --pdf document.pdf --page 8 --adjust && python picture_extractor.py --doctags results/output.doctags.txt --pdf document.pdf --page 8 --adjust
docker-compose up --build
```
## 🐛 Troubleshooting
Open [http://localhost:3000](http://localhost:3000)
### Docker Issues
### Local Development
1. **Container won't start**
- Check logs: `docker-compose logs analyser`
- Ensure ports aren't in use: `lsof -i :8080`
2. **"No module named 'docling_core'" error**
- Rebuild the container: `docker-compose down && docker-compose up -d --build`
3. **Analysis stuck on "Running..."**
- First run downloads the AI model (~500MB), this can take 5-10 minutes
- Check progress: `docker-compose exec analyser du -sh /root/.cache/huggingface/`
- Monitor CPU usage: `docker-compose exec analyser ps aux | grep analyzer`
4. **PDF not loading**
- Ensure poppler is installed (already included in Dockerfile)
- Place PDFs in the project root directory
- PDFs must have `.pdf` extension
### Performance Tips
- First analysis is slow due to model download
- Subsequent analyses are much faster (model is cached)
- Processing time depends on PDF complexity and page size
- Monitor memory usage: `docker-compose exec analyser free -h`
## 📁 Project Structure
```
doc-analyzer/
├── backend/
│ ├── page_treatment/ # Core processing scripts
│ │ ├── analyzer.py # AI-powered document analysis
│ │ ├── visualizer.py # Visualization generator
│ │ └── picture_extractor.py # Image extraction
│ ├── app.py # Flask web application
│ └── requirements.txt # Python dependencies
├── frontend/ # Web interface
├── results/ # Output directory (auto-created)
├── Dockerfile # Docker configuration
└── docker-compose.yml # Docker Compose setup
**Document Parser:**
```bash
cd document-parser
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
```
## 🔧 Development
**Backend:**
```bash
cd backend
./mvnw spring-boot:run
```
To modify the application:
**Frontend:**
```bash
cd frontend
npm install
npm run dev
```
1. Make changes to the code
2. Rebuild the Docker image: `docker-compose up -d --build`
3. Check logs for errors: `docker-compose logs -f analyser`
## Features
## 📄 License
This project is open source and available under the MIT License.
- PDF upload and document analysis via Docling
- Extracted content viewing (Markdown, HTML)
- Document structure visualization with bounding boxes
- Image detection
- Analysis history

17
backend/Dockerfile Normal file
View 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"]

View file

View file

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

View file

@ -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}")

View file

@ -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'],
},
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +0,0 @@
transformers
accelerate
torch
torchvision
pdf2image
pillow
requests
flask
docling_core

View file

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

View file

@ -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();
}
}

View file

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

View file

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

View file

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

View file

@ -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()
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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());
}
}

View file

@ -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()
);
}
}

View file

@ -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());
}
}

View file

@ -0,0 +1,7 @@
package com.docling.studio.shared.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}

View file

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

View 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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
databaseChangeLog:
- include:
file: db/changelog/001-create-documents.yml
- include:
file: db/changelog/002-create-analysis-jobs.yml

View file

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

View file

@ -1,7 +1,47 @@
services:
analyser:
build: .
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: docling_studio
ports:
- "8080:5000"
- "5432:5432"
volumes:
- ./:/app
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d docling_studio"]
interval: 5s
timeout: 5s
retries: 10
document-parser:
build:
context: ./document-parser
ports:
- "8000:8000"
backend:
build:
context: ./backend
ports:
- "8081:8081"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/docling_studio
SPRING_DATASOURCE_USERNAME: app
SPRING_DATASOURCE_PASSWORD: app
APP_DOCUMENT-PARSER_BASE-URL: http://document-parser:8000
depends_on:
postgres:
condition: service_healthy
frontend:
build:
context: ./frontend
ports:
- "3000:80"
depends_on:
- backend
volumes:
postgres_data:

Binary file not shown.

View 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
View 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"}

View file

@ -0,0 +1,6 @@
docling
fastapi
uvicorn[standard]
python-multipart
pdf2image
pillow

14
frontend/Dockerfile Normal file
View 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

View file

@ -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()">&times;</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>

View file

@ -1,176 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SCUB-Doc Intelligence Suite - Professional Document Analysis</title>
<link rel="stylesheet" href="static/styles.css">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docling Studio</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<div class="container">
<!-- Professional Enterprise-Style Title -->
<div class="title-enterprise">
<div class="brand-icon">
<svg viewBox="0 0 24 24">
<path d="M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z"/>
</svg>
</div>
<div class="brand-text">
<h1>
DocTags Intelligence Suite
<span class="pro-badge">AI powered</span>
</h1>
<p class="tagline">Enterprise-grade document processing and analysis</p>
</div>
</div>
<!-- Settings Panel -->
<div class="settings-panel">
<div class="settings-row">
<div class="form-group pdf-group">
<div><label for="pdf_file">PDF Document</label><div id="pdf-load-status"></div></div>
<select id="pdf_file" name="pdf_file">
<option value="">Select a PDF file...</option>
</select>
</div>
<div class="form-group">
<label for="page_num">Page Number</label>
<input type="number" id="page_num" name="page_num" value="1" min="1" placeholder="1">
</div>
<div class="form-group">
<label for="adjust">
<input type="checkbox" id="adjust" name="adjust" checked>
Auto-adjust coordinates
</label>
</div>
</div>
</div>
<!-- Progress Indicator -->
<div class="progress-indicator">
<div class="step-indicator" id="step-1">1</div>
<div class="step-connector" id="connector-1"></div>
<div class="step-indicator" id="step-2">2</div>
<div class="step-connector" id="connector-2"></div>
<div class="step-indicator" id="step-3">3</div>
</div>
<!-- Tabs Container -->
<div class="tabs-container">
<!-- Tab Buttons -->
<div class="tab-buttons">
<button class="tab-button active" onclick="switchTab('analyzer')">
Analyze Document
</button>
<button class="tab-button" onclick="switchTab('visualizer')">
Visualize Structure
</button>
<button class="tab-button" onclick="switchTab('extractor')">
Extract Images
</button>
</div>
<!-- Tab Contents -->
<div id="analyzer-tab" class="tab-content active">
<h3>Document Analysis</h3>
<p>Extract comprehensive document structure from your PDF using advanced AI-powered analysis. This process identifies headers, paragraphs, images, tables, and other document elements with precise coordinate information.</p>
<button id="analyzer-btn" onclick="runScript('analyzer')">
Start Analysis
</button>
<div id="analyzer-status" class="status hidden"></div>
<!-- PDF Preview -->
<div id="pdf-preview-container" class="hidden">
<h4>PDF Preview - Page <span id="preview-page-num">1</span></h4>
<div class="preview-controls">
<button class="nav-btn" onclick="changePreviewPage(-1)"> Previous</button>
<div class="page-selector">
<label>Page:</label>
<input type="number" id="preview-page-input" min="1" value="1" class="page-input">
<button class="go-btn" onclick="goToPreviewPage()">Go</button>
</div>
<button class="nav-btn" onclick="changePreviewPage(1)">Next </button>
</div>
<img id="pdf-preview-image" src="" alt="PDF Preview">
</div>
<div class="tab-description">
<strong>Analysis Process:</strong>
<ul>
<li>Converts your PDF page into a high-resolution image</li>
<li>Applies machine learning to identify document structure</li>
<li>Maps elements with precise pixel coordinates</li>
<li>Generates structured DocTags format output</li>
<li>Saves results to the <code>results/</code> directory</li>
</ul>
</div>
</div>
<div id="visualizer-tab" class="tab-content">
<h3>Structure Visualization</h3>
<p>Generate a visual representation of the analyzed document structure with color-coded bounding boxes around each detected element type.</p>
<button id="visualizer-btn" onclick="runScript('visualizer')" disabled>
Create Visualization
</button>
<div id="visualizer-status" class="status hidden"></div>
<div id="image-container" class="hidden">
<h4>Visualization Result</h4>
<img id="result-image" src="" alt="Document Structure Visualization">
</div>
<div class="tab-description">
<strong>Visualization Features:</strong>
<ul>
<li>Color-coded element boundaries (headers, text, images, tables)</li>
<li>Element type labels for easy identification</li>
<li>Accurate coordinate mapping and scaling</li>
<li>High-quality PNG output for documentation</li>
<li>Interactive overlay on original document</li>
</ul>
</div>
</div>
<div id="extractor-tab" class="tab-content">
<h3>Image Extraction</h3>
<p>Automatically extract and save individual images from your document based on the detected picture regions, complete with metadata and captions.</p>
<button id="extractor-btn" onclick="runScript('extractor')" disabled>
Extract All Images
</button>
<div id="extractor-status" class="status hidden"></div>
<!-- Extracted Images Gallery -->
<div id="extracted-images-container" class="hidden">
<h4>Extracted Images</h4>
<div id="extracted-images-gallery" class="images-gallery">
<!-- Images will be loaded here dynamically -->
</div>
</div>
<div class="tab-description">
<strong>Extraction Capabilities:</strong>
<ul>
<li>Precise image boundary detection and cropping</li>
<li>Caption and metadata preservation</li>
<li>Organized file naming and directory structure</li>
<li>HTML index page for easy browsing</li>
<li>Automatic quality optimization and resizing</li>
</ul>
</div>
</div>
</div>
<!-- Output Section -->
<div id="output" class="output hidden"></div>
</div>
<script src="static/app.js"></script>
<div id="app"></div>
<script type="module" src="/src/app/main.js"></script>
</body>
</html>
</html>

17
frontend/nginx.conf Normal file
View 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

File diff suppressed because it is too large Load diff

24
frontend/package.json Normal file
View 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"
}
}

View 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
View 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
View 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')

View 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
})

View 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' })
}

View 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'

View 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 }
})

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

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

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

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

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

View 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}`
}

View 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'

View 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 }
})

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

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

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

View 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' })
}

View file

@ -0,0 +1,2 @@
export { useHistoryStore } from './store.js'
export { default as HistoryList } from './ui/HistoryList.vue'

View 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 }
})

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

View file

@ -0,0 +1,2 @@
export { useSettingsStore } from './store.js'
export { default as SettingsPanel } from './ui/SettingsPanel.vue'

View 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 }
})

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

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

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

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

View 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()
}

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

View file

@ -0,0 +1 @@
export { default as AppSidebar } from './AppSidebar.vue'

View file

@ -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()">&times;</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 {}
});

View file

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

View file

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

View file

@ -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
View 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
}
}
}
})

View file

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