diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..030b05a --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "frontend-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000, + "cwd": "frontend" + } + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 958faf8..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 93cd1e6..c256853 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.run/DoclingStudio Backend.run.xml b/.run/DoclingStudio Backend.run.xml new file mode 100644 index 0000000..85802c5 --- /dev/null +++ b/.run/DoclingStudio Backend.run.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e581fd9..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] diff --git a/README.md b/README.md index 295c1e1..e0d4a42 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,51 @@ -# DocTags Analyzer and Visualizer +# Docling Studio -AI-powered document analysis and visualization tool for extracting structured content from PDFs. +A professional document analysis studio powered by [Docling](https://github.com/DS4SD/docling), inspired by MistralAI Studio. -image +## 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 - 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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..11f3ea3 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__init__.py b/backend/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app.py b/backend/app.py deleted file mode 100644 index c91845e..0000000 --- a/backend/app.py +++ /dev/null @@ -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/') -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/') -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//') -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/') -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/') -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/') - 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/') -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//') -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/', 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/', 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/', 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/') -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) \ No newline at end of file diff --git a/backend/batch_treatment/__init__.py b/backend/batch_treatment/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/batch_treatment/batch_processor.py b/backend/batch_treatment/batch_processor.py deleted file mode 100644 index 3ac4f43..0000000 --- a/backend/batch_treatment/batch_processor.py +++ /dev/null @@ -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 '' 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""" - - - - Batch Processing Report - {self.pdf_file} - - - -
-

Batch Processing Report

-

Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

- -
-
-
{self.total_pages}
-
Total Pages
-
-
-
{self.state['results']['successful']}
-
Successful
-
-
-
{self.state['results']['failed']}
-
Failed
-
-
-
{self.state['results']['totalImages']}
-
Images Extracted
-
-
-
{format_duration(duration)}
-
Processing Time
-
-
-
{success_rate:.1f}%
-
Success Rate
-
-
-""" - - # Add failed pages if any - if self.state['results']['failedPages']: - html += """ -

Failed Pages

- - -""" - for failed in self.state['results']['failedPages']: - html += f"\n" - html += "
Page NumberReason
{failed['pageNum']}{failed['reason']}
\n" - - 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}") \ No newline at end of file diff --git a/backend/config.py b/backend/config.py deleted file mode 100644 index 8f35d0e..0000000 --- a/backend/config.py +++ /dev/null @@ -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'], - }, -} \ No newline at end of file diff --git a/backend/multipart_handler.py b/backend/multipart_handler.py deleted file mode 100644 index a115342..0000000 --- a/backend/multipart_handler.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/backend/page_treatment/__init__.py b/backend/page_treatment/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/page_treatment/analyzer.py b/backend/page_treatment/analyzer.py deleted file mode 100644 index 36762d3..0000000 --- a/backend/page_treatment/analyzer.py +++ /dev/null @@ -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() diff --git a/backend/page_treatment/picture_extractor.py b/backend/page_treatment/picture_extractor.py deleted file mode 100644 index a624f0d..0000000 --- a/backend/page_treatment/picture_extractor.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env python3 -""" -DocTags Picture Extractor - Extract 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'.*?(.*?)' - -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'', '', 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""" - - - - Extracted Pictures from {pdf_name} - Page {page_num} - - - -

Extracted Pictures from {pdf_name} - Page {page_num}

-

Total pictures found: {len(pictures)}

-""" - - if pictures: - html += ' \n' - else: - html += '
\n

No pictures found on this page

\n
\n' - - html += '\n\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() \ No newline at end of file diff --git a/backend/page_treatment/visualizer.py b/backend/page_treatment/visualizer.py deleted file mode 100644 index e10635d..0000000 --- a/backend/page_treatment/visualizer.py +++ /dev/null @@ -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'' - -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 tags - doctag_match = re.search(r'(.*?)', doctags_content, re.DOTALL) - if not doctag_match: - raise ValueError("No 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}.*?' - 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).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'.*?' - 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).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'', 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() \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..6303db3 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.docling + docling-studio + 0.1.0 + Docling Studio Backend + + + 21 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.postgresql + postgresql + runtime + + + org.liquibase + liquibase-core + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index dc2ad71..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -transformers -accelerate -torch -torchvision -pdf2image -pillow -requests -flask -docling_core \ No newline at end of file diff --git a/backend/src/main/java/com/docling/studio/DoclingStudioApplication.java b/backend/src/main/java/com/docling/studio/DoclingStudioApplication.java new file mode 100644 index 0000000..b8e6711 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/DoclingStudioApplication.java @@ -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); + } +} diff --git a/backend/src/main/java/com/docling/studio/analysis/AnalysisController.java b/backend/src/main/java/com/docling/studio/analysis/AnalysisController.java new file mode 100644 index 0000000..0dc25ff --- /dev/null +++ b/backend/src/main/java/com/docling/studio/analysis/AnalysisController.java @@ -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 body) { + UUID documentId = UUID.fromString(body.get("documentId")); + AnalysisJob job = service.create(documentId); + return AnalysisResponse.from(job); + } + + @GetMapping + public List 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 delete(@PathVariable UUID id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/docling/studio/analysis/AnalysisJob.java b/backend/src/main/java/com/docling/studio/analysis/AnalysisJob.java new file mode 100644 index 0000000..070e893 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/analysis/AnalysisJob.java @@ -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; } +} diff --git a/backend/src/main/java/com/docling/studio/analysis/AnalysisJobRepository.java b/backend/src/main/java/com/docling/studio/analysis/AnalysisJobRepository.java new file mode 100644 index 0000000..044bc3b --- /dev/null +++ b/backend/src/main/java/com/docling/studio/analysis/AnalysisJobRepository.java @@ -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 { + List findAllByOrderByCreatedAtDesc(); + List findByDocumentIdOrderByCreatedAtDesc(UUID documentId); +} diff --git a/backend/src/main/java/com/docling/studio/analysis/AnalysisService.java b/backend/src/main/java/com/docling/studio/analysis/AnalysisService.java new file mode 100644 index 0000000..ffb08cd --- /dev/null +++ b/backend/src/main/java/com/docling/studio/analysis/AnalysisService.java @@ -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 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 findAll() { + return repository.findAllByOrderByCreatedAtDesc(); + } + + public void delete(UUID id) { + AnalysisJob job = findById(id); + repository.delete(job); + } +} diff --git a/backend/src/main/java/com/docling/studio/analysis/dto/AnalysisResponse.java b/backend/src/main/java/com/docling/studio/analysis/dto/AnalysisResponse.java new file mode 100644 index 0000000..0e4f6a0 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/analysis/dto/AnalysisResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/com/docling/studio/config/AsyncConfig.java b/backend/src/main/java/com/docling/studio/config/AsyncConfig.java new file mode 100644 index 0000000..828475b --- /dev/null +++ b/backend/src/main/java/com/docling/studio/config/AsyncConfig.java @@ -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; + } +} diff --git a/backend/src/main/java/com/docling/studio/config/DocumentParserProperties.java b/backend/src/main/java/com/docling/studio/config/DocumentParserProperties.java new file mode 100644 index 0000000..6b6723f --- /dev/null +++ b/backend/src/main/java/com/docling/studio/config/DocumentParserProperties.java @@ -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; + } +} diff --git a/backend/src/main/java/com/docling/studio/config/WebConfig.java b/backend/src/main/java/com/docling/studio/config/WebConfig.java new file mode 100644 index 0000000..a6a3113 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/config/WebConfig.java @@ -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); + } +} diff --git a/backend/src/main/java/com/docling/studio/document/Document.java b/backend/src/main/java/com/docling/studio/document/Document.java new file mode 100644 index 0000000..acfb293 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/document/Document.java @@ -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; } +} diff --git a/backend/src/main/java/com/docling/studio/document/DocumentController.java b/backend/src/main/java/com/docling/studio/document/DocumentController.java new file mode 100644 index 0000000..32fc4a4 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/document/DocumentController.java @@ -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 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 delete(@PathVariable UUID id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping(value = "/{id}/preview") + public ResponseEntity 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); + } +} diff --git a/backend/src/main/java/com/docling/studio/document/DocumentParserClient.java b/backend/src/main/java/com/docling/studio/document/DocumentParserClient.java new file mode 100644 index 0000000..8dbed2e --- /dev/null +++ b/backend/src/main/java/com/docling/studio/document/DocumentParserClient.java @@ -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 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; + } + } +} diff --git a/backend/src/main/java/com/docling/studio/document/DocumentRepository.java b/backend/src/main/java/com/docling/studio/document/DocumentRepository.java new file mode 100644 index 0000000..0926527 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/document/DocumentRepository.java @@ -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 { +} diff --git a/backend/src/main/java/com/docling/studio/document/DocumentService.java b/backend/src/main/java/com/docling/studio/document/DocumentService.java new file mode 100644 index 0000000..99639eb --- /dev/null +++ b/backend/src/main/java/com/docling/studio/document/DocumentService.java @@ -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 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()); + } +} diff --git a/backend/src/main/java/com/docling/studio/document/dto/DocumentResponse.java b/backend/src/main/java/com/docling/studio/document/dto/DocumentResponse.java new file mode 100644 index 0000000..6b0ec63 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/document/dto/DocumentResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/com/docling/studio/shared/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/docling/studio/shared/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d598726 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/shared/exception/GlobalExceptionHandler.java @@ -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()); + } +} diff --git a/backend/src/main/java/com/docling/studio/shared/exception/ResourceNotFoundException.java b/backend/src/main/java/com/docling/studio/shared/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..013d442 --- /dev/null +++ b/backend/src/main/java/com/docling/studio/shared/exception/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package com.docling.studio.shared.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/docling/studio/shared/exception/ServiceException.java b/backend/src/main/java/com/docling/studio/shared/exception/ServiceException.java new file mode 100644 index 0000000..44f326a --- /dev/null +++ b/backend/src/main/java/com/docling/studio/shared/exception/ServiceException.java @@ -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); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..a7e3374 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -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 diff --git a/backend/src/main/resources/db/changelog/001-create-documents.yml b/backend/src/main/resources/db/changelog/001-create-documents.yml new file mode 100644 index 0000000..3f0f317 --- /dev/null +++ b/backend/src/main/resources/db/changelog/001-create-documents.yml @@ -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() diff --git a/backend/src/main/resources/db/changelog/002-create-analysis-jobs.yml b/backend/src/main/resources/db/changelog/002-create-analysis-jobs.yml new file mode 100644 index 0000000..d695702 --- /dev/null +++ b/backend/src/main/resources/db/changelog/002-create-analysis-jobs.yml @@ -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() diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.yml b/backend/src/main/resources/db/changelog/db.changelog-master.yml new file mode 100644 index 0000000..70cba0f --- /dev/null +++ b/backend/src/main/resources/db/changelog/db.changelog-master.yml @@ -0,0 +1,5 @@ +databaseChangeLog: + - include: + file: db/changelog/001-create-documents.yml + - include: + file: db/changelog/002-create-analysis-jobs.yml diff --git a/backend/utils.py b/backend/utils.py deleted file mode 100644 index f5f9c82..0000000 --- a/backend/utils.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..622529a --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index dfe914d..990e20c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file + - 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: diff --git a/docs/document.pdf b/docs/document.pdf deleted file mode 100644 index c2a2c57..0000000 Binary files a/docs/document.pdf and /dev/null differ diff --git a/document-parser/Dockerfile b/document-parser/Dockerfile new file mode 100644 index 0000000..b047eee --- /dev/null +++ b/document-parser/Dockerfile @@ -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"] diff --git a/document-parser/main.py b/document-parser/main.py new file mode 100644 index 0000000..bf6878c --- /dev/null +++ b/document-parser/main.py @@ -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"} diff --git a/document-parser/requirements.txt b/document-parser/requirements.txt new file mode 100644 index 0000000..e1a213d --- /dev/null +++ b/document-parser/requirements.txt @@ -0,0 +1,6 @@ +docling +fastapi +uvicorn[standard] +python-multipart +pdf2image +pillow diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c1c36cb --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/batch.html b/frontend/batch.html deleted file mode 100644 index 7732b6d..0000000 --- a/frontend/batch.html +++ /dev/null @@ -1,251 +0,0 @@ - - - - - - DocTags Batch Processor - - - - -
-

DocTags Batch Processor

- - - - - -
-

Batch Processing Configuration

- - -
-
- - - -
- - -
- -
- - -
- -
- - -
- -
- - - -
-
-
-
- - - - - -
- - - - -
- - - - - - - - - - - - -
- - -
- -
- - - - \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 72dda17..b5dfc1a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,176 +1,13 @@ - - - SCUB-Doc Intelligence Suite - Professional Document Analysis - + + + Docling Studio + -
- -
-
- - - -
-
-

- DocTags Intelligence Suite - AI powered -

-

Enterprise-grade document processing and analysis

-
-
- - -
-
-
-
- - -
-
- - -
-
- -
-
-
- - -
-
1
-
-
2
-
-
3
-
- - -
- -
- - - -
- - -
-

Document Analysis

-

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.

- - - - - - - - -
- Analysis Process: -
    -
  • Converts your PDF page into a high-resolution image
  • -
  • Applies machine learning to identify document structure
  • -
  • Maps elements with precise pixel coordinates
  • -
  • Generates structured DocTags format output
  • -
  • Saves results to the results/ directory
  • -
-
-
- -
-

Structure Visualization

-

Generate a visual representation of the analyzed document structure with color-coded bounding boxes around each detected element type.

- - - - - - - -
- Visualization Features: -
    -
  • Color-coded element boundaries (headers, text, images, tables)
  • -
  • Element type labels for easy identification
  • -
  • Accurate coordinate mapping and scaling
  • -
  • High-quality PNG output for documentation
  • -
  • Interactive overlay on original document
  • -
-
-
- -
-

Image Extraction

-

Automatically extract and save individual images from your document based on the detected picture regions, complete with metadata and captions.

- - - - - - - - -
- Extraction Capabilities: -
    -
  • Precise image boundary detection and cropping
  • -
  • Caption and metadata preservation
  • -
  • Organized file naming and directory structure
  • -
  • HTML index page for easy browsing
  • -
  • Automatic quality optimization and resizing
  • -
-
-
-
- - - -
- - +
+ - \ No newline at end of file + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..ca50c32 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..79887c8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1601 @@ +{ + "name": "docling-studio", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docling-studio", + "version": "0.1.0", + "dependencies": { + "marked": "^17.0.4", + "pinia": "^2.3.0", + "vue": "^3.4.0", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "vite": "^5.4.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e297ee5 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..7ee399c --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + D + diff --git a/frontend/src/app/App.vue b/frontend/src/app/App.vue new file mode 100644 index 0000000..080b9a6 --- /dev/null +++ b/frontend/src/app/App.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/frontend/src/app/main.js b/frontend/src/app/main.js new file mode 100644 index 0000000..a6b920b --- /dev/null +++ b/frontend/src/app/main.js @@ -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') diff --git a/frontend/src/app/router/index.js b/frontend/src/app/router/index.js new file mode 100644 index 0000000..610e825 --- /dev/null +++ b/frontend/src/app/router/index.js @@ -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 +}) diff --git a/frontend/src/features/analysis/api.js b/frontend/src/features/analysis/api.js new file mode 100644 index 0000000..ee0209f --- /dev/null +++ b/frontend/src/features/analysis/api.js @@ -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' }) +} diff --git a/frontend/src/features/analysis/index.js b/frontend/src/features/analysis/index.js new file mode 100644 index 0000000..543130e --- /dev/null +++ b/frontend/src/features/analysis/index.js @@ -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' diff --git a/frontend/src/features/analysis/store.js b/frontend/src/features/analysis/store.js new file mode 100644 index 0000000..a99eec6 --- /dev/null +++ b/frontend/src/features/analysis/store.js @@ -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 } +}) diff --git a/frontend/src/features/analysis/ui/AnalysisPanel.vue b/frontend/src/features/analysis/ui/AnalysisPanel.vue new file mode 100644 index 0000000..9c77e88 --- /dev/null +++ b/frontend/src/features/analysis/ui/AnalysisPanel.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/frontend/src/features/analysis/ui/ImageGallery.vue b/frontend/src/features/analysis/ui/ImageGallery.vue new file mode 100644 index 0000000..7149b33 --- /dev/null +++ b/frontend/src/features/analysis/ui/ImageGallery.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/features/analysis/ui/MarkdownViewer.vue b/frontend/src/features/analysis/ui/MarkdownViewer.vue new file mode 100644 index 0000000..c7771cc --- /dev/null +++ b/frontend/src/features/analysis/ui/MarkdownViewer.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/frontend/src/features/analysis/ui/ResultTabs.vue b/frontend/src/features/analysis/ui/ResultTabs.vue new file mode 100644 index 0000000..4c7a967 --- /dev/null +++ b/frontend/src/features/analysis/ui/ResultTabs.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/frontend/src/features/analysis/ui/StructureViewer.vue b/frontend/src/features/analysis/ui/StructureViewer.vue new file mode 100644 index 0000000..0263d2a --- /dev/null +++ b/frontend/src/features/analysis/ui/StructureViewer.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/frontend/src/features/document/api.js b/frontend/src/features/document/api.js new file mode 100644 index 0000000..dc03f3a --- /dev/null +++ b/frontend/src/features/document/api.js @@ -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}` +} diff --git a/frontend/src/features/document/index.js b/frontend/src/features/document/index.js new file mode 100644 index 0000000..aa8e7d3 --- /dev/null +++ b/frontend/src/features/document/index.js @@ -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' diff --git a/frontend/src/features/document/store.js b/frontend/src/features/document/store.js new file mode 100644 index 0000000..af0b2e9 --- /dev/null +++ b/frontend/src/features/document/store.js @@ -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 } +}) diff --git a/frontend/src/features/document/ui/DocumentList.vue b/frontend/src/features/document/ui/DocumentList.vue new file mode 100644 index 0000000..12f526b --- /dev/null +++ b/frontend/src/features/document/ui/DocumentList.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/features/document/ui/DocumentUpload.vue b/frontend/src/features/document/ui/DocumentUpload.vue new file mode 100644 index 0000000..4954f77 --- /dev/null +++ b/frontend/src/features/document/ui/DocumentUpload.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/features/document/ui/PagePreview.vue b/frontend/src/features/document/ui/PagePreview.vue new file mode 100644 index 0000000..66a88b1 --- /dev/null +++ b/frontend/src/features/document/ui/PagePreview.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/frontend/src/features/history/api.js b/frontend/src/features/history/api.js new file mode 100644 index 0000000..664e780 --- /dev/null +++ b/frontend/src/features/history/api.js @@ -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' }) +} diff --git a/frontend/src/features/history/index.js b/frontend/src/features/history/index.js new file mode 100644 index 0000000..b6cdce6 --- /dev/null +++ b/frontend/src/features/history/index.js @@ -0,0 +1,2 @@ +export { useHistoryStore } from './store.js' +export { default as HistoryList } from './ui/HistoryList.vue' diff --git a/frontend/src/features/history/store.js b/frontend/src/features/history/store.js new file mode 100644 index 0000000..055dde1 --- /dev/null +++ b/frontend/src/features/history/store.js @@ -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 } +}) diff --git a/frontend/src/features/history/ui/HistoryList.vue b/frontend/src/features/history/ui/HistoryList.vue new file mode 100644 index 0000000..2da71e3 --- /dev/null +++ b/frontend/src/features/history/ui/HistoryList.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/features/settings/index.js b/frontend/src/features/settings/index.js new file mode 100644 index 0000000..d5f88e5 --- /dev/null +++ b/frontend/src/features/settings/index.js @@ -0,0 +1,2 @@ +export { useSettingsStore } from './store.js' +export { default as SettingsPanel } from './ui/SettingsPanel.vue' diff --git a/frontend/src/features/settings/store.js b/frontend/src/features/settings/store.js new file mode 100644 index 0000000..2cb9a10 --- /dev/null +++ b/frontend/src/features/settings/store.js @@ -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 } +}) diff --git a/frontend/src/features/settings/ui/SettingsPanel.vue b/frontend/src/features/settings/ui/SettingsPanel.vue new file mode 100644 index 0000000..5f0c428 --- /dev/null +++ b/frontend/src/features/settings/ui/SettingsPanel.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/frontend/src/pages/HistoryPage.vue b/frontend/src/pages/HistoryPage.vue new file mode 100644 index 0000000..115e67b --- /dev/null +++ b/frontend/src/pages/HistoryPage.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue new file mode 100644 index 0000000..25af3a4 --- /dev/null +++ b/frontend/src/pages/SettingsPage.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frontend/src/pages/StudioPage.vue b/frontend/src/pages/StudioPage.vue new file mode 100644 index 0000000..533a526 --- /dev/null +++ b/frontend/src/pages/StudioPage.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/shared/api/http.js b/frontend/src/shared/api/http.js new file mode 100644 index 0000000..716b472 --- /dev/null +++ b/frontend/src/shared/api/http.js @@ -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() +} diff --git a/frontend/src/shared/ui/AppSidebar.vue b/frontend/src/shared/ui/AppSidebar.vue new file mode 100644 index 0000000..59383ec --- /dev/null +++ b/frontend/src/shared/ui/AppSidebar.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/frontend/src/shared/ui/index.js b/frontend/src/shared/ui/index.js new file mode 100644 index 0000000..a8769d5 --- /dev/null +++ b/frontend/src/shared/ui/index.js @@ -0,0 +1 @@ +export { default as AppSidebar } from './AppSidebar.vue' diff --git a/frontend/static/app.js b/frontend/static/app.js deleted file mode 100644 index c51a81c..0000000 --- a/frontend/static/app.js +++ /dev/null @@ -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`, '
Running...'); - ui.show(`${taskInfo.type}-status`); - } -} - -function handleTaskSuccess(taskId, taskInfo, data, statusElement) { - ui.setHtml(`${taskInfo.type}-status`, '✓ Completed successfully!'); - 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`, '✗ Failed: ' + (data.error || 'Unknown error') + ''); - 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`, '
Starting...'); - 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`, '✗ ' + error.message + ''); - 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 = '
No images were extracted from this page.
'; - 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 += ` -
-
- Extracted Image ${pictureId} -
-
-

Picture ${pictureId}

- ${captionText ? `

${captionText}

` : ''} -

${coordsText}

-
-
- `; - } - }); - - imageGallery.innerHTML = galleryHTML; - ui.show('extracted-images-container'); - appState.generatedOutputs.extractor = true; - } catch (error) { - console.log('No extracted images found:', error); - imageGallery.innerHTML = '
No images have been extracted yet. Run the image extraction first.
'; - 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 = ` - - `; - 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', '
Checking environment...'); - - try { - const response = await fetch('/check-environment'); - const data = await response.json(); - - let html = '
    '; - html += `
  • Working directory: ${data.cwd}
  • `; - html += `
  • Python version: ${data.python_version}
  • `; - - if (data.missing_scripts.length === 0) { - html += '
  • ✓ All required scripts found
  • '; - } else { - html += `
  • ✗ Missing scripts: ${data.missing_scripts.join(', ')}
  • `; - } - - if (data.pdf_files.length > 0) { - html += `
  • ✓ Found ${data.pdf_files.length} PDF files
  • `; - } else { - html += '
  • ✗ No PDF files found in the working directory
  • '; - } - - html += '
'; - ui.setHtml('env-details', html); - } catch (error) { - ui.setHtml('env-details', `
Error checking environment: ${error.message}
`); - } -} - -// 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', '
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', 'No PDF files found'); - return; - } - - data.forEach(file => { - const option = document.createElement('option'); - option.value = file; - option.textContent = file; - select.appendChild(option); - }); - - ui.setHtml('pdf-load-status', `Loaded ${data.length} PDF files`); - } catch (error) { - ui.setHtml('pdf-load-status', 'Error: ' + error.message + ''); - } - - // 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 {} -}); \ No newline at end of file diff --git a/frontend/static/batch-processor.js b/frontend/static/batch-processor.js deleted file mode 100644 index f996e3c..0000000 --- a/frontend/static/batch-processor.js +++ /dev/null @@ -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 = ` -
Page ${pageNum}
-
- - `; - 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 = '
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 = '

No images extracted from this page

'; - return; - } - - let galleryHTML = ''; - images.forEach(img => { - const src = img.getAttribute('src'); - galleryHTML += ` - - `; - }); - - gallery.innerHTML = galleryHTML; - }) - .catch(error => { - gallery.innerHTML = '

No extracted images available

'; - }); -} - -// 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 = ` - Page ${page.pageNum} - ${page.reason} - - `; - 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 = `[${timestamp}] ${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 = '
    '; - if (data.missing_scripts.length > 0) { - html += '
  • Missing scripts: ' + data.missing_scripts.join(', ') + '
  • '; - } - if (data.pdf_files.length === 0) { - html += '
  • No PDF files found in working directory
  • '; - } - html += '
'; - - envDetails.innerHTML = html; - } - }) - .catch(error => { - console.error('Error checking environment:', error); - }); -} \ No newline at end of file diff --git a/frontend/static/batch-styles.css b/frontend/static/batch-styles.css deleted file mode 100644 index b397723..0000000 --- a/frontend/static/batch-styles.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/frontend/static/styles.css b/frontend/static/styles.css deleted file mode 100644 index 4995e0a..0000000 --- a/frontend/static/styles.css +++ /dev/null @@ -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); - } - -} \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..1816d6c --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } +}) diff --git a/run_app.sh b/run_app.sh deleted file mode 100755 index ffa3abc..0000000 --- a/run_app.sh +++ /dev/null @@ -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 \ No newline at end of file