App upload
This commit is contained in:
Drakonis96 2025-03-10 11:21:21 +01:00
parent 8080b9de7d
commit ee77b2989f
22 changed files with 1774 additions and 0 deletions

38
Dockerfile Normal file
View file

@ -0,0 +1,38 @@
# Stage 1: Build the frontend
FROM node:16-alpine as frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
COPY frontend/ .
RUN npm run build
# Stage 2: Build the backend
FROM python:3.9-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
poppler-utils \
tesseract-ocr \
ghostscript \
libffi-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Install OCRmyPDF
RUN pip install --no-cache-dir ocrmypdf
# Copy backend code
COPY backend/ /app/backend/
# Copy built frontend assets into the backend's static folder
COPY --from=frontend-build /app/frontend/build/ /app/backend/static/
# Set working directory to backend and install Python dependencies
WORKDIR /app/backend
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5015
CMD ["python", "app.py"]

81
README.md Normal file
View file

@ -0,0 +1,81 @@
<center>
<img src="logo.png" alt="ocrAI Logo" width="150">
</center>
# ocrAI 🤖
ocrAI is a unified web application that combines Optical Character Recognition (OCR) and Artificial Intelligence (AI) to process and translate documents, offering a simple, intuitive interface with real-time feedback (even with emojis!).
## Key Features
- **File Management** 📤
- Upload PDF or image files using drag & drop or manual selection.
- Files are saved with unique names to avoid overwrites.
- The "Delete All Files" button removes all files from both the "uploads" and "outputs" folders.
- **OCR Processing Modes** 🔍
- **OCR (Tesseract Only):**
Extracts text with Tesseract and embeds it into the PDF using OCRmyPDF. The TXT file contains the raw OCR output.
- **OCR + AI (Tesseract + AI):**
Uses Tesseract to extract text and then sends it to an AI model (e.g., Gemini) to correct and format the content. The TXT file shows the corrected and structured text, while the PDF retains the original Tesseract output.
- **AI (Full AI OCR):**
Leverages the AI model's OCR capabilities to process the document page by page. The TXT file includes clear page markers, making it easy to compare with the original document, and the original PDF is preserved.
- All modes display real-time progress updates with emojis (e.g., 📤, ✅, 🤖, 🎉) and run in the background.
- **Translation** 🌐
- Translates PDF or TXT documents page by page.
- You can upload a new file or select one from the list of processed files.
- Progress updates are displayed, and a TXT file with the final translation (including page markers) is generated.
- **Configuration** ⚙️
- Manage and add new AI models (including the ability to add or delete Gemini models) and languages.
- Update or add custom prompts for OCR, correction, and translation functions.
- Download or upload the complete configuration (which includes prompts and models).
## How to Use the Application
1. **Upload and Process Files:**
- Go to the **OCR** tab.
- Select your file (PDF or image).
- Choose one of the processing modes:
- **OCR** (Tesseract Only)
- **OCR + AI** (Tesseract + AI for correction)
- **AI** (Full AI OCR)
- Select the desired prompt.
- Click **Upload and process** and watch the real-time progress.
2. **Translate Documents:**
- Go to the **Translation** tab.
- Upload a new file or select one from the list of processed files.
- Choose the target language and translation prompt.
- Click **Translate** and observe the progress as each page is processed.
- The result is saved in a TXT file with page markers.
3. **View Processed Files:**
- Go to the **Processed Files** tab.
- Download or delete files (with confirmation prompts).
4. **Configure the Application:**
- Go to the **Configurations** tab.
- Add, edit, or delete custom prompts.
- Manage Gemini models: add new models or delete existing ones.
- Configure languages and download or upload the complete configuration.
## How to Run ocrAI
### Prerequisites
- Docker
- Docker Compose
### Build and Run
```bash
docker-compose up --build
Then, open your browser at http://localhost:5015 to start using ocrAI.
Technologies Used
Frontend: React, Axios
Backend: Flask, Python
OCR: Tesseract, pdf2image, OCRmyPDF
AI: OpenAI, Gemini, Mistral APIs
Containerization: Docker, Docker Compose

BIN
backend/.DS_Store vendored Normal file

Binary file not shown.

257
backend/app.py Normal file
View file

@ -0,0 +1,257 @@
# backend/app.py
import os
import uuid
import threading
import json
from flask import Flask, request, jsonify, send_from_directory
from flask_cors import CORS
from werkzeug.utils import secure_filename
from models import get_models, add_model, get_languages, update_prompt, get_prompt, add_language, delete_prompt, custom_prompts, default_prompts
from utils import process_file, translate_file_by_pages, convert_txt_to_pdf
import time
app = Flask(__name__, static_folder="static", static_url_path="")
CORS(app)
UPLOAD_FOLDER = "uploads"
OUTPUT_FOLDER = "outputs"
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
# Global dictionary for background jobs
active_jobs = {} # job_id: {"progress": int, "status": str, "cancelled": bool, "result": any}
def update_progress(job_id, progress, status):
active_jobs[job_id]["progress"] = progress
active_jobs[job_id]["status"] = status
def is_cancelled(job_id):
return active_jobs[job_id]["cancelled"]
def run_processing(job_id, file_path, api, model, mode, prompt_key):
try:
result = process_file(
file_path, api, model, mode, prompt_key,
update_progress=lambda prog, stat: update_progress(job_id, prog, stat),
is_cancelled=lambda: is_cancelled(job_id)
)
active_jobs[job_id]["result"] = result
update_progress(job_id, 100, "🎉 Process completed")
except Exception as e:
update_progress(job_id, active_jobs[job_id]["progress"], f"❌ Error: {str(e)}")
def run_translation(job_id, file_path, api, model, target_language, prompt_key):
try:
result = translate_file_by_pages(
file_path, api, model, target_language, prompt_key,
update_progress=lambda prog, stat: update_progress(job_id, prog, stat),
is_cancelled=lambda: is_cancelled(job_id)
)
base_name = os.path.splitext(os.path.basename(file_path))[0]
translation_file = os.path.join(OUTPUT_FOLDER, base_name + "_translation.txt")
with open(translation_file, "w", encoding="utf-8") as f:
f.write(result)
active_jobs[job_id]["result"] = translation_file
update_progress(job_id, 100, "🎉 Process completed")
except Exception as e:
update_progress(job_id, active_jobs[job_id]["progress"], f"❌ Error: {str(e)}")
@app.route('/api/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({"error": "No file found"}), 400
file = request.files['file']
api = request.form.get('api')
model = request.form.get('model')
mode = request.form.get('mode') # "OCR", "OCR + AI" or "AI"
prompt_key = request.form.get('prompt_key')
if file.filename == '':
return jsonify({"error": "Empty filename"}), 400
filename = secure_filename(file.filename)
if os.path.exists(os.path.join(UPLOAD_FOLDER, filename)):
base, ext = os.path.splitext(filename)
filename = f"{base}_{uuid.uuid4().hex}{ext}"
file_path = os.path.join(UPLOAD_FOLDER, filename)
file.save(file_path)
job_id = str(uuid.uuid4())
active_jobs[job_id] = {"progress": 0, "status": "📤 File uploaded", "cancelled": False, "result": None}
thread = threading.Thread(target=run_processing, args=(job_id, file_path, api, model, mode, prompt_key))
thread.start()
return jsonify({"message": "File uploaded, processing started", "job_id": job_id})
@app.route('/api/progress/<job_id>', methods=['GET'])
def get_progress(job_id):
if job_id in active_jobs:
return jsonify({
"progress": active_jobs[job_id]["progress"],
"status": active_jobs[job_id]["status"],
"result": active_jobs[job_id]["result"]
})
else:
return jsonify({"error": "Job not found"}), 404
@app.route('/api/stop/<job_id>', methods=['POST'])
def stop_job(job_id):
if job_id in active_jobs:
active_jobs[job_id]["cancelled"] = True
update_progress(job_id, active_jobs[job_id]["progress"], "⏹️ Cancelled")
return jsonify({"message": "Job cancellation requested"})
else:
return jsonify({"error": "Job not found"}), 404
@app.route('/api/models', methods=['GET'])
def models():
api = request.args.get('api')
if api:
models_list = get_models(api)
return jsonify({"models": models_list})
else:
return jsonify({"error": "Must specify API"}), 400
@app.route('/api/languages', methods=['GET'])
def languages():
langs = get_languages()
return jsonify({"languages": langs})
@app.route('/api/add-model', methods=['POST'])
def add_new_model():
data = request.get_json()
api = data.get("api")
model = data.get("model")
if not api or not model:
return jsonify({"error": "Missing api or model"}), 400
add_model(api, model)
return jsonify({"message": f"Model {model} added for {api}"}), 200
# Nuevo endpoint para eliminar un modelo
@app.route('/api/delete-model', methods=['DELETE'])
def delete_model_endpoint():
data = request.get_json()
api_name = data.get("api")
model_name = data.get("model")
if not api_name or not model_name:
return jsonify({"error": "Missing api or model"}), 400
from models import delete_model
if delete_model(api_name, model_name):
return jsonify({"message": f"Model {model_name} deleted from {api_name}."}), 200
else:
return jsonify({"error": "Model not found."}), 404
@app.route('/api/prompts', methods=['GET'])
def get_prompts_endpoint():
prompts = {}
prompts.update(default_prompts)
from models import custom_prompts
prompts.update(custom_prompts)
return jsonify({"prompts": prompts})
@app.route('/api/prompts', methods=['POST'])
def update_prompts_endpoint():
data = request.get_json()
key = data.get("key")
new_prompt = data.get("prompt")
if not key or not new_prompt:
return jsonify({"error": "Missing key or prompt"}), 400
update_prompt(key, new_prompt)
return jsonify({"message": f"Prompt for '{key}' updated."})
@app.route('/api/prompts/<key>', methods=['DELETE'])
def delete_prompt_endpoint(key):
if delete_prompt(key):
return jsonify({"message": f"Prompt '{key}' deleted."})
else:
return jsonify({"error": "Prompt not found or cannot be deleted."}), 404
@app.route('/api/files', methods=['GET'])
def list_files():
files = os.listdir(OUTPUT_FOLDER)
return jsonify({"files": files})
@app.route('/api/files/<filename>', methods=['GET'])
def download_file(filename):
return send_from_directory(OUTPUT_FOLDER, filename, as_attachment=True)
@app.route('/api/files/<filename>', methods=['DELETE'])
def delete_file(filename):
file_path = os.path.join(OUTPUT_FOLDER, filename)
if os.path.exists(file_path):
os.remove(file_path)
return jsonify({"message": "File deleted"}), 200
else:
return jsonify({"error": "File not found"}), 404
# Endpoint modificado: borrar todos los archivos tanto de la carpeta outputs como de uploads.
@app.route('/api/files/all', methods=['DELETE'])
def delete_all_files():
try:
# Borrar archivos de OUTPUT_FOLDER
output_files = os.listdir(OUTPUT_FOLDER)
for file in output_files:
file_path = os.path.join(OUTPUT_FOLDER, file)
os.remove(file_path)
# Borrar archivos de UPLOAD_FOLDER
upload_files = os.listdir(UPLOAD_FOLDER)
for file in upload_files:
file_path = os.path.join(UPLOAD_FOLDER, file)
os.remove(file_path)
return jsonify({"message": "All files in outputs and uploads deleted"}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/config', methods=['GET'])
def download_config():
from models import custom_prompts, available_models
config = {
"custom_prompts": custom_prompts,
"available_models": available_models
}
return jsonify(config)
@app.route('/api/config', methods=['POST'])
def upload_config():
if 'config' not in request.files:
return jsonify({"error": "No config file provided"}), 400
file = request.files['config']
try:
config_data = json.load(file)
from models import custom_prompts, available_models
custom_prompts.clear()
custom_prompts.update(config_data.get("custom_prompts", {}))
available_models.clear()
available_models.update(config_data.get("available_models", {}))
return jsonify({"message": "Configuration updated successfully"}), 200
except Exception as e:
return jsonify({"error": str(e)}), 400
@app.route('/api/txttopdf', methods=['POST'])
def txt_to_pdf_endpoint():
data = request.get_json()
filename = data.get("filename")
if not filename:
return jsonify({"error": "Missing filename parameter"}), 400
txt_path = os.path.join(OUTPUT_FOLDER, filename)
if not os.path.exists(txt_path):
return jsonify({"error": "File not found"}), 404
try:
pdf_path = convert_txt_to_pdf(txt_path)
return jsonify({"message": "TXT to PDF conversion completed", "pdf_file": os.path.basename(pdf_path)})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def serve(path):
if path != "" and os.path.exists(os.path.join(app.static_folder, path)):
return send_from_directory(app.static_folder, path)
else:
return send_from_directory(app.static_folder, 'index.html')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5015)

53
backend/models.py Normal file
View file

@ -0,0 +1,53 @@
# backend/models.py
default_prompts = {
"ocr_correction": "Correct the following text. Begin your answer directly with the corrected text. Format the result as HTML, indicating where each page begins, each paragraph begins, and marking titles with the appropriate HTML tags (e.g., <h1>, <h2>, etc.).",
"ocr": "Perform OCR on the following document. Begin your answer directly with the OCR. Format the result as HTML, indicating where each page begins, each paragraph begins, and marking titles with the appropriate HTML tags (e.g., <h1>, <h2>, etc.).",
"translation": "Translate the following text to Spanish. Do not add any commentary; only output the translated text. Begin your answer directly with the translation. Format the result as HTML, indicating where each page begins, each paragraph begins, and marking titles with the appropriate HTML tags (e.g., <h1>, <h2>, etc.)."
}
custom_prompts = {}
def get_prompt(key):
return custom_prompts.get(key, default_prompts.get(key, ""))
def update_prompt(key, new_prompt):
custom_prompts[key] = new_prompt
def delete_prompt(key):
if key in custom_prompts:
del custom_prompts[key]
return True
elif key in default_prompts:
custom_prompts[key] = ""
return True
return False
# Solo se usará Gemini.
available_models = {
"Gemini": ["gemini-2.0-flash"]
}
def get_models(api_name):
return available_models.get(api_name, [])
def add_model(api_name, model_name):
if api_name in available_models:
if model_name not in available_models[api_name]:
available_models[api_name].append(model_name)
else:
available_models[api_name] = [model_name]
def delete_model(api, model):
if api in available_models and model in available_models[api]:
available_models[api].remove(model)
return True
return False
available_languages = ["Spanish", "English", "French", "Italian", "German", "Portuguese"]
def get_languages():
return available_languages
def add_language(language):
if language not in available_languages:
available_languages.append(language)

9
backend/requirements.txt Normal file
View file

@ -0,0 +1,9 @@
Flask
flask-cors
pytesseract
Pillow
pdf2image
google-genai
openai
mistralai
beautifulsoup4

BIN
backend/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

358
backend/utils.py Normal file
View file

@ -0,0 +1,358 @@
# backend/utils.py
import os
import time
import pytesseract
from PIL import Image
from pdf2image import convert_from_path
import shutil
import asyncio
import uuid
import subprocess
import base64
import re
from models import get_prompt
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_JUSTIFY, TA_CENTER
# Se agrega BeautifulSoup para procesar HTML
from bs4 import BeautifulSoup
UPLOAD_FOLDER = "uploads"
OUTPUT_FOLDER = "outputs"
def encode_image(file_path):
with open(file_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def run_tesseract(file_path):
"""
If the file is a PDF, perform OCR page by page and add a "Page X:" header;
otherwise, perform OCR normally.
"""
extracted_text = ""
if file_path.lower().endswith(".pdf"):
try:
pages = convert_from_path(file_path)
for i, page in enumerate(pages, start=1):
page_text = pytesseract.image_to_string(page, lang='eng')
extracted_text += f"Page {i}:\n{page_text}\n\n"
except Exception as e:
extracted_text = f"❌ Error processing PDF: {str(e)}"
else:
image = Image.open(file_path)
extracted_text = pytesseract.image_to_string(image, lang='eng')
return extracted_text
def call_api_correction(api, model, text, prompt_key="ocr_correction"):
prompt = get_prompt(prompt_key) + text
try:
from google import genai
except ImportError:
raise ImportError("Please install 'google-genai' to use Gemini.")
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
response = client.models.generate_content(model=model, contents=[prompt])
return response.text
def call_api_ocr(api, model, file_path, prompt_key="ocr"):
try:
from google import genai
except ImportError:
raise ImportError("Please install 'google-genai' to use Gemini.")
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
file_ref = client.files.upload(file=file_path)
prompt = get_prompt(prompt_key)
response = client.models.generate_content(model=model, contents=[file_ref, prompt])
return response.text
def call_api_translation(api, model, text, target_language, prompt_key="translation"):
prompt_template = get_prompt(prompt_key)
prompt = prompt_template.format(target_language=target_language) + text
try:
from google import genai
except ImportError:
raise ImportError("Please install 'google-genai' to use Gemini.")
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
response = client.models.generate_content(model=model, contents=[prompt])
return response.text
def embed_ocr_in_pdf(input_pdf, output_pdf):
try:
subprocess.run(["ocrmypdf", input_pdf, output_pdf], check=True)
return True
except Exception as e:
return False
def ocr_file_by_pages(file_path, api, model, prompt_key, update_progress, is_cancelled):
final_text = ""
if file_path.lower().endswith(".pdf"):
try:
pages = convert_from_path(file_path)
except Exception as e:
return f"❌ Error processing PDF: {str(e)}"
total = len(pages)
for i, page in enumerate(pages, start=1):
if is_cancelled():
update_progress(0, "⏹️ Cancelled")
return "Process cancelled."
temp_filename = os.path.join(OUTPUT_FOLDER, f"temp_page_{uuid.uuid4().hex}.png")
page.save(temp_filename, "PNG")
page_text = call_api_ocr(api, model, temp_filename, prompt_key)
final_text += f"Page {i}:\n{page_text}\n\n"
os.remove(temp_filename)
progress = int((i / total) * 100)
update_progress(progress, f"📄 Processed page {i} of {total}.")
time.sleep(1)
return final_text
else:
return call_api_ocr(api, model, file_path, prompt_key)
def translate_file_by_pages(file_path, api, model, target_language, prompt_key, update_progress, is_cancelled):
final_translation = ""
if file_path.lower().endswith(".pdf"):
try:
pages = convert_from_path(file_path)
except Exception as e:
return f"❌ Error processing PDF: {str(e)}"
total = len(pages)
for i, page in enumerate(pages, start=1):
if is_cancelled():
update_progress(0, "⏹️ Cancelled")
return "Process cancelled."
temp_filename = os.path.join(OUTPUT_FOLDER, f"temp_page_{uuid.uuid4().hex}.png")
page.save(temp_filename, "PNG")
page_text = pytesseract.image_to_string(page, lang='eng')
translated_page = call_api_translation(api, model, page_text, target_language, prompt_key)
final_translation += f"Page {i}:\n{translated_page}\n\n"
os.remove(temp_filename)
progress = int((i / total) * 100)
update_progress(progress, f"📄 Processed page {i} of {total}.")
time.sleep(1)
return final_translation
elif file_path.lower().endswith(".txt"):
with open(file_path, "r", encoding="utf-8") as f:
text = f.read()
translated = call_api_translation(api, model, text, target_language, prompt_key)
update_progress(100, "🎉 Process completed")
return f"Page 1:\n{translated}"
else:
return "Unsupported file type for translation."
def process_file(file_path, api, model, mode, prompt_key, update_progress, is_cancelled):
if is_cancelled():
update_progress(0, "⏹️ Cancelled")
return "Process cancelled."
update_progress(25, "📤 File uploaded.")
base_name = os.path.splitext(os.path.basename(file_path))[0]
if mode == "OCR":
# Process using tesseract with page-structure if PDF.
processed_text = run_tesseract(file_path)
if is_cancelled():
update_progress(25, "⏹️ Cancelled")
return "Process cancelled."
update_progress(50, "✅ Tesseract OCR completed.")
# Generate PDF copy as before.
pdf_output = os.path.join(OUTPUT_FOLDER, base_name + "_ocr.pdf")
if file_path.lower().endswith(".pdf"):
if embed_ocr_in_pdf(file_path, pdf_output):
update_progress(95, "📄 OCR embedded into PDF.")
else:
shutil.copy(file_path, pdf_output)
update_progress(95, "⚠️ Failed to embed OCR; original PDF copied.")
else:
shutil.copy(file_path, pdf_output)
elif mode == "OCR + AI":
processed_text = run_tesseract(file_path)
if is_cancelled():
update_progress(25, "⏹️ Cancelled")
return "Process cancelled."
update_progress(50, "✅ Tesseract OCR completed.")
processed_text = call_api_correction(api, model, processed_text, prompt_key)
pdf_output = os.path.join(OUTPUT_FOLDER, base_name + "_ocr.pdf")
if file_path.lower().endswith(".pdf"):
if embed_ocr_in_pdf(file_path, pdf_output):
update_progress(95, "📄 OCR embedded into PDF.")
else:
shutil.copy(file_path, pdf_output)
update_progress(95, "⚠️ Failed to embed OCR; original PDF copied.")
else:
shutil.copy(file_path, pdf_output)
elif mode == "AI":
update_progress(25, "📂 File ready for full AI processing.")
if file_path.lower().endswith(".pdf"):
processed_text = ocr_file_by_pages(file_path, api, model, prompt_key, update_progress, is_cancelled)
else:
processed_text = call_api_ocr(api, model, file_path, prompt_key)
# In AI mode, do not generate a new PDF.
else:
processed_text = "Unrecognized processing mode."
update_progress(25, "❌ Error: Unrecognized mode.")
if is_cancelled():
update_progress(75, "⏹️ Cancelled")
return "Process cancelled."
update_progress(75, "🤖 API processing completed.")
# Write the output TXT file (it is already structured by page in OCR and OCR+AI modes)
txt_file = os.path.join(OUTPUT_FOLDER, base_name + ".txt")
with open(txt_file, "w", encoding="utf-8") as f:
f.write(processed_text)
update_progress(100, "🎉 Process completed")
return processed_text
def process_text(text):
lines = text.splitlines()
processed_lines = []
buffer = ""
for line in lines:
stripped = line.strip()
if not stripped:
if buffer:
processed_lines.append(buffer)
buffer = ""
else:
processed_lines.append("")
continue
if buffer:
if buffer.endswith('.'):
processed_lines.append(buffer)
buffer = stripped
else:
buffer += " " + stripped
else:
buffer = stripped
if buffer:
processed_lines.append(buffer)
return "\n".join(processed_lines)
def convert_txt_to_pdf(txt_file_path):
"""
Se ha modificado para que, si el contenido del TXT está formateado en HTML,
se interpreten los tags que indiquen títulos (<h1>, <h2>, etc.), párrafos (<p>)
y saltos de página (<pagebreak> o div con clase "page-break").
En caso de que el contenido sea texto plano y contenga patrones en el formato
[Page X] (entre corchetes), se usará ese separador para dividir las páginas.
El encabezado (sin los corchetes) se incluirá en la parte superior de cada página.
"""
with open(txt_file_path, "r", encoding="utf-8") as f:
content = f.read()
base_name = os.path.splitext(os.path.basename(txt_file_path))[0]
output_pdf = os.path.join(OUTPUT_FOLDER, base_name + "_txt.pdf")
doc = SimpleDocTemplate(output_pdf, pagesize=A4,
rightMargin=40, leftMargin=40,
topMargin=40, bottomMargin=40)
styles = getSampleStyleSheet()
# Estilos para encabezados
header_styles = {
"h1": ParagraphStyle('Heading1', parent=styles['Heading1'], alignment=TA_CENTER),
"h2": ParagraphStyle('Heading2', parent=styles['Heading2'], alignment=TA_CENTER),
"h3": ParagraphStyle('Heading3', parent=styles['Heading3'], alignment=TA_CENTER),
"h4": ParagraphStyle('Heading4', parent=styles['Heading4'], alignment=TA_CENTER),
"h5": ParagraphStyle('Heading5', parent=styles['Heading5'], alignment=TA_CENTER),
"h6": ParagraphStyle('Heading6', parent=styles['Heading6'], alignment=TA_CENTER),
}
normal_style = ParagraphStyle(
'Normal',
parent=styles['Normal'],
alignment=TA_JUSTIFY,
leading=15,
leftIndent=20 # Sangría al inicio de cada párrafo
)
flowables = []
# Detectamos si el contenido es HTML (buscando etiquetas comunes)
is_html = any(tag in content.lower() for tag in ["<html", "<p", "<h1", "<h2", "<h3"])
if is_html:
soup = BeautifulSoup(content, "html.parser")
body = soup.body if soup.body else soup
for element in body.children:
if element.name is None:
text = element.strip()
if text:
para = Paragraph(text, normal_style)
flowables.append(para)
flowables.append(Spacer(1, 12))
elif element.name.lower() in ["h1", "h2", "h3", "h4", "h5", "h6"]:
tag = element.name.lower()
style = header_styles.get(tag, styles['Heading1'])
para = Paragraph(element.decode_contents(), style)
flowables.append(para)
flowables.append(Spacer(1, 12))
elif element.name.lower() == "p":
para = Paragraph(element.decode_contents(), normal_style)
flowables.append(para)
flowables.append(Spacer(1, 12))
elif element.name.lower() == "pagebreak" or (element.name.lower() == "div" and "page-break" in element.get("class", [])):
flowables.append(PageBreak())
else:
text = element.get_text().strip()
if text:
para = Paragraph(text, normal_style)
flowables.append(para)
flowables.append(Spacer(1, 12))
else:
# Si se detecta el patrón [Page X] en el contenido, se usa para dividir las páginas.
if re.search(r'\[Page\s+\d+\]', content, re.IGNORECASE):
# Dividir incluyendo el separador (usamos grupo de captura)
parts = re.split(r'(\[Page\s+\d+\])', content, flags=re.IGNORECASE)
current_header = ""
for part in parts:
part = part.strip()
if not part:
continue
# Si es un encabezado [Page X]
if re.match(r'\[Page\s+\d+\]', part, re.IGNORECASE):
# Extraemos el número o texto sin los corchetes para usarlo como encabezado
current_header = part.strip("[]")
header_para = Paragraph(current_header, styles['Heading1'])
flowables.append(header_para)
flowables.append(Spacer(1, 12))
else:
# Es el contenido de la página
processed = process_text(part)
para = Paragraph(processed.replace("\n", "<br/>"), normal_style)
flowables.append(para)
flowables.append(Spacer(1, 12))
flowables.append(PageBreak())
else:
# Si no se detectan separadores, se usa el método anterior basado en "Page X:" sin corchetes.
pages = re.split(r'(?i)Page\s+\d+:\s*', content)
if pages and pages[0].strip() == "":
pages = pages[1:]
if len(pages) <= 1:
pages = content.split("\n\n")
processed_pages = [process_text(page) for page in pages if page.strip() != ""]
for i, page_text in enumerate(processed_pages, start=1):
header = Paragraph(f"PAGE {i}", styles['Heading1'])
flowables.append(header)
flowables.append(Spacer(1, 12))
para = Paragraph(page_text.replace("\n", "<br/>"), normal_style)
flowables.append(para)
if i < len(processed_pages):
flowables.append(Spacer(1, 24))
flowables.append(PageBreak())
doc.build(flowables)
return output_pdf

11
docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
services:
webapp:
build: .
container_name: ocrai
ports:
- "5015:5015"
volumes:
- ./backend/uploads:/app/backend/uploads
- ./backend/outputs:/app/backend/outputs
environment:
- GEMINI_API_KEY=your_api_key

BIN
frontend/.DS_Store vendored Normal file

Binary file not shown.

17
frontend/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "ocrai-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"axios": "^0.27.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}
}

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ocrAI</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

124
frontend/src/App.css Normal file
View file

@ -0,0 +1,124 @@
/* frontend/src/App.css */
body {
margin: 0;
font-family: Arial, sans-serif;
}
.app-container {
text-align: center;
padding: 20px;
}
/* Header: Logo and title centered */
.app-header {
text-align: center;
margin-bottom: 20px;
}
.logo-container {
display: flex;
flex-direction: column;
align-items: center;
}
.app-logo {
height: 100px; /* Aumenta el tamaño del logo */
}
.app-title {
margin-top: 10px;
font-size: 2em;
color: #333;
}
/* Navigation styling */
.app-nav {
margin-top: 20px;
display: flex;
justify-content: center;
gap: 10px;
}
.tab-processing {
padding: 10px 20px;
border: none;
background-color: #ADD8E6;
cursor: pointer;
border-radius: 5px;
}
.tab-default {
padding: 10px 20px;
border: none;
background-color: #ddd;
cursor: pointer;
border-radius: 5px;
}
/* Nueva regla para la pestaña TXT to PDF con color distinto (light purple) */
.tab-txttopdf {
padding: 10px 20px;
border: none;
background-color: #dda0dd;
cursor: pointer;
border-radius: 5px;
}
.app-nav button.active {
opacity: 0.8;
}
main {
margin-top: 20px;
}
/* Processed Files grouping and text wrapping */
.file-group {
border: 1px solid #ccc;
border-radius: 8px;
margin-bottom: 10px;
padding: 10px;
}
.file-group-title {
word-wrap: break-word;
margin: 0 0 10px 0;
font-weight: bold;
}
.file-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.button-download {
background-color: green;
color: white;
border: none;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
}
.button-delete {
background-color: pink;
color: white;
border: none;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.app-header {
flex-direction: column;
}
.app-nav {
flex-direction: column;
}
.file-group {
width: 90%;
}
}

66
frontend/src/App.js Normal file
View file

@ -0,0 +1,66 @@
// frontend/src/App.js
import React, { useState } from 'react';
import FileUpload from './components/FileUpload';
import FileList from './components/FileList';
import Configurations from './components/Configurations';
import TxtToPdf from './components/TxtToPdf';
import Notifications from './components/Notifications';
function App() {
const [activeTab, setActiveTab] = useState('ocrAI');
const [notifications, setNotifications] = useState([]);
const handleJobCompleted = (notification) => {
setNotifications(prev => [...prev, notification]);
};
const clearNotifications = () => {
setNotifications([]);
};
return (
<div className="app-container">
<header className="app-header">
<div className="logo-container">
<img src="/logo.png" alt="Logo" className="app-logo" />
<h1 className="app-title">ocrAI</h1>
</div>
</header>
<nav className="app-nav">
<button
onClick={() => setActiveTab('ocrAI')}
className={activeTab === 'ocrAI' ? 'active tab-processing' : 'tab-processing'}
>
💡 ocrAI
</button>
<button
onClick={() => setActiveTab('files')}
className={activeTab === 'files' ? 'active tab-default' : 'tab-default'}
>
📁 Processed Files
</button>
<button
onClick={() => setActiveTab('configurations')}
className={activeTab === 'configurations' ? 'active tab-default' : 'tab-default'}
>
Configurations
</button>
<button
onClick={() => setActiveTab('txttopdf')}
className={activeTab === 'txttopdf' ? 'active tab-txttopdf' : 'tab-txttopdf'}
>
📝 TXT to PDF
</button>
</nav>
<main>
{activeTab === 'ocrAI' && <FileUpload onJobCompleted={handleJobCompleted} />}
{activeTab === 'files' && <FileList />}
{activeTab === 'configurations' && <Configurations />}
{activeTab === 'txttopdf' && <TxtToPdf />}
</main>
<Notifications notifications={notifications} onClear={clearNotifications} />
</div>
);
}
export default App;

View file

@ -0,0 +1,256 @@
// frontend/src/components/Configurations.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const API_URL = '/api';
function Configurations() {
// ----- ESTADOS PARA PROMPTS -----
const [newPromptKey, setNewPromptKey] = useState('');
const [newPromptText, setNewPromptText] = useState('');
const [message, setMessage] = useState('');
const [prompts, setPrompts] = useState({});
// ----- ESTADOS PARA MODELOS GEMINI -----
const [models, setModels] = useState([]); // lista de modelos actuales
const [newModel, setNewModel] = useState(''); // nombre del modelo que vamos a añadir
// ----- ESTADOS PARA SUBIR/DESCARGAR CONFIG -----
const [configFile, setConfigFile] = useState(null);
// ----- FUNCIONALIDAD DE PROMPTS -----
const fetchPrompts = () => {
axios.get(`${API_URL}/prompts`)
.then(response => setPrompts(response.data.prompts))
.catch(err => console.error(err));
};
const handleAddPrompt = (e) => {
e.preventDefault();
if (!newPromptKey || !newPromptText) {
setMessage("⚠️ Please fill in both key and prompt text.");
return;
}
axios.post(`${API_URL}/prompts`, { key: newPromptKey, prompt: newPromptText })
.then(response => {
setMessage(response.data.message);
setNewPromptKey('');
setNewPromptText('');
fetchPrompts();
})
.catch(err => {
setMessage("❌ Error adding prompt.");
console.error(err);
});
};
const handleDeletePrompt = (key) => {
if (window.confirm("Are you sure you want to delete this prompt?")) {
axios.delete(`${API_URL}/prompts/${key}`)
.then(response => {
setMessage(response.data.message);
fetchPrompts();
})
.catch(err => {
setMessage("❌ Error deleting prompt.");
console.error(err);
});
}
};
// ----- FUNCIONALIDAD DE MODELOS GEMINI -----
const fetchModels = () => {
axios.get(`${API_URL}/models?api=Gemini`)
.then(response => {
setModels(response.data.models || []);
})
.catch(err => {
console.error("❌ Error fetching models:", err);
});
};
const handleAddModel = (e) => {
e.preventDefault();
if (!newModel.trim()) {
setMessage("⚠️ Please enter a model name.");
return;
}
axios.post(`${API_URL}/add-model`, { api: "Gemini", model: newModel.trim() })
.then(response => {
setMessage(response.data.message);
setNewModel('');
fetchModels();
})
.catch(err => {
setMessage("❌ Error adding model.");
console.error(err);
});
};
const handleDeleteModel = (modelName) => {
if (window.confirm(`Are you sure you want to delete model ${modelName}?`)) {
axios.delete(`${API_URL}/delete-model`, { data: { api: "Gemini", model: modelName } })
.then(response => {
setMessage(response.data.message);
fetchModels();
})
.catch(err => {
setMessage("❌ Error deleting model.");
console.error(err);
});
}
};
// ----- FUNCIONALIDAD DE CONFIGURACIÓN (DOWNLOAD / UPLOAD) -----
const handleDownloadConfig = () => {
axios.get(`${API_URL}/config`)
.then(response => {
const data = response.data;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'config.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
})
.catch(err => {
setMessage("❌ Error downloading config.");
console.error(err);
});
};
const handleConfigFileChange = (e) => {
setConfigFile(e.target.files[0]);
};
const handleUploadConfig = () => {
if (!configFile) {
setMessage("⚠️ Please select a config file to upload.");
return;
}
const formData = new FormData();
formData.append("config", configFile);
axios.post(`${API_URL}/config`, formData, { headers: { "Content-Type": "multipart/form-data" } })
.then(response => {
setMessage(response.data.message);
fetchPrompts();
fetchModels();
})
.catch(err => {
setMessage("❌ Error uploading config.");
console.error(err);
});
};
// ----- useEffect -----
useEffect(() => {
fetchPrompts();
fetchModels();
}, []);
// ----- RENDER -----
return (
<div style={{ textAlign: 'left', maxWidth: '600px', margin: '0 auto' }}>
<h2>Configurations </h2>
{/* Sección: Prompts */}
<div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
<h3>Add / Edit Prompt</h3>
<form onSubmit={handleAddPrompt}>
<div style={{ marginBottom: '10px' }}>
<label>
Prompt Key:
<input
type="text"
value={newPromptKey}
onChange={(e) => setNewPromptKey(e.target.value)}
placeholder="e.g., custom_prompt"
style={{ marginLeft: '10px', width: '100%' }}
/>
</label>
</div>
<div style={{ marginBottom: '10px' }}>
<label>
Prompt Text:
<textarea
value={newPromptText}
onChange={(e) => setNewPromptText(e.target.value)}
placeholder="Enter the prompt text"
style={{ marginLeft: '10px', width: '100%' }}
rows="4"
/>
</label>
</div>
<button type="submit">Save Prompt</button>
</form>
</div>
<div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
<h3>Current Prompts</h3>
{Object.keys(prompts).length === 0 ? (
<p>No prompts available.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{Object.entries(prompts).map(([key, text]) => (
<li key={key} style={{ marginBottom: '5px', display: 'flex', justifyContent: 'space-between' }}>
<span><strong>{key}:</strong> {text}</span>
<button onClick={() => handleDeletePrompt(key)} style={{ fontSize: '12px', padding: '2px 5px' }}>
Delete
</button>
</li>
))}
</ul>
)}
</div>
{/* Sección: Modelos Gemini */}
<div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
<h3>Gemini Models</h3>
{models.length === 0 ? (
<p>No Gemini models found.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{models.map((model, i) => (
<li key={i} style={{ marginBottom: '5px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{model}</span>
<button onClick={() => handleDeleteModel(model)} style={{ fontSize: '12px', padding: '2px 5px' }}>
Delete
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleAddModel} style={{ marginTop: '10px' }}>
<label>
New model name:
<input
type="text"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder="Enter new model name"
style={{ marginLeft: '10px', width: '60%' }}
/>
</label>
<button type="submit" style={{ marginLeft: '10px' }}>Add Model</button>
</form>
</div>
{/* Sección: Download / Upload Config */}
<div style={{ marginBottom: '20px', border: '1px solid #ccc', padding: '15px', borderRadius: '8px' }}>
<h3>Download / Upload Configuration</h3>
<button onClick={handleDownloadConfig}>Download Config</button>
<div style={{ marginTop: '10px' }}>
<input type="file" onChange={handleConfigFileChange} />
<button onClick={handleUploadConfig} style={{ marginLeft: '10px' }}>Upload Config</button>
</div>
</div>
{message && <p>{message}</p>}
</div>
);
}
export default Configurations;

View file

@ -0,0 +1,94 @@
// frontend/src/components/FileList.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const API_URL = '/api';
function FileList() {
const [files, setFiles] = useState([]);
const fetchFiles = () => {
axios.get(`${API_URL}/files`)
.then(response => setFiles(response.data.files))
.catch(err => console.error(err));
};
useEffect(() => {
fetchFiles();
}, []);
const handleDownload = (filename) => {
window.location.href = `${API_URL}/files/${filename}`;
};
const handleDelete = (filename) => {
if (window.confirm("Are you sure you want to delete this file?")) {
axios.delete(`${API_URL}/files/${filename}`)
.then(response => fetchFiles())
.catch(err => console.error(err));
}
};
const handleDeleteAll = () => {
if (window.confirm("Are you sure you want to delete ALL files? This action cannot be undone.")) {
axios.delete(`${API_URL}/files/all`)
.then(response => fetchFiles())
.catch(err => console.error(err));
}
};
// Group files by base name (without extension)
const groupedFiles = files.reduce((groups, file) => {
const base = file.replace(/\.[^.]+$/, '');
if (!groups[base]) {
groups[base] = [];
}
groups[base].push(file);
return groups;
}, {});
return (
<div style={{ textAlign: 'left', maxWidth: '600px', margin: '0 auto' }}>
<h2>Processed Files</h2>
{Object.keys(groupedFiles).length === 0 ? (
<p>No files available.</p>
) : (
<>
{Object.keys(groupedFiles).map(base => (
<div key={base} className="file-group">
<h3 className="file-group-title">{base}</h3>
<div className="file-buttons">
{groupedFiles[base].map((file, index) => (
<div key={index}>
<button onClick={() => handleDownload(file)} className="button-download">
📥
</button>
<button onClick={() => handleDelete(file)} className="button-delete">
🗑
</button>
</div>
))}
</div>
</div>
))}
<button
onClick={handleDeleteAll}
style={{
marginTop: '20px',
backgroundColor: 'red',
color: 'white',
padding: '10px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer'
}}
>
Delete All Files
</button>
</>
)}
</div>
);
}
export default FileList;

View file

@ -0,0 +1,198 @@
// frontend/src/components/FileUpload.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import ProgressBar from './ProgressBar';
import ModelSelector from './ModelSelector';
const API_URL = '/api';
function FileUpload({ onJobCompleted }) {
// Se establece la API por defecto en "Gemini"
const [file, setFile] = useState(null);
const [api, setApi] = useState('Gemini');
const [model, setModel] = useState('');
const [mode, setMode] = useState('OCR + AI'); // Opciones: "OCR", "OCR + AI" o "AI"
const [promptKey, setPromptKey] = useState('');
const [availablePrompts, setAvailablePrompts] = useState({});
const [models, setModels] = useState([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [message, setMessage] = useState('');
const [jobId, setJobId] = useState(null);
// No se ofrece opción para seleccionar otra API ya que sólo se usa Gemini.
useEffect(() => {
axios.get(`${API_URL}/models`, { params: { api } })
.then(response => {
setModels(response.data.models);
if (response.data.models.length > 0) {
setModel(response.data.models[0]);
}
})
.catch(err => console.error(err));
}, [api]);
useEffect(() => {
axios.get(`${API_URL}/prompts`)
.then(response => setAvailablePrompts(response.data.prompts))
.catch(err => console.error(err));
}, []);
useEffect(() => {
if (jobId) {
const interval = setInterval(() => {
axios.get(`${API_URL}/progress/${jobId}`)
.then(response => {
const data = response.data;
setUploadProgress(data.progress);
setMessage(data.status);
if (data.progress === 100 || data.status.includes("Cancelled") || data.status.includes("Error")) {
clearInterval(interval);
onJobCompleted && onJobCompleted("Processing job completed");
setJobId(null);
}
})
.catch(err => console.error(err));
}, 2000);
return () => clearInterval(interval);
}
}, [jobId, onJobCompleted]);
const handleFileChange = (e) => setFile(e.target.files[0]);
const handleDrop = (e) => {
e.preventDefault();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
setFile(e.dataTransfer.files[0]);
e.dataTransfer.clearData();
}
};
const handleDragOver = (e) => e.preventDefault();
const handleStop = () => {
if (jobId) {
axios.post(`${API_URL}/stop/${jobId}`)
.then(response => {
setMessage("⏹️ Process stopped by user");
setJobId(null);
})
.catch(err => console.error(err));
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (!file) {
setMessage("⚠️ Please select a file.");
return;
}
if (mode === "OCR") {
setPromptKey('');
} else if (!promptKey) {
setMessage("⚠️ Please select a prompt.");
return;
}
setMessage("🚀 Uploading file...");
const formData = new FormData();
formData.append("file", file);
// La API siempre es "Gemini"
formData.append("api", api);
formData.append("model", model);
formData.append("mode", mode);
formData.append("prompt_key", promptKey);
axios.post(`${API_URL}/upload`, formData, { headers: { "Content-Type": "multipart/form-data" } })
.then(response => {
setMessage("📤 File uploaded. Processing started.");
setJobId(response.data.job_id);
})
.catch(err => {
setMessage("❌ Error uploading file.");
console.error(err);
});
};
return (
<div>
<form onSubmit={handleSubmit} className="upload-form">
<div
className="drop-zone"
onDrop={handleDrop}
onDragOver={handleDragOver}
style={{
border: '2px dashed #ccc',
padding: '20px',
borderRadius: '5px',
marginBottom: '10px'
}}
>
{file ? <p>📄 {file.name}</p> : <p>📂 Drag and drop the file here or click to select</p>}
<input type="file" onChange={handleFileChange} style={{ display: 'none' }} id="fileInput" />
<label htmlFor="fileInput" style={{ cursor: 'pointer', color: 'blue' }}>Select file</label>
</div>
<div className="selectors" style={{ marginBottom: '10px' }}>
{/* No se muestra opción de seleccionar otra API */}
<ModelSelector models={models} selectedModel={model} setSelectedModel={setModel} />
</div>
<div className="mode-selector" style={{ marginBottom: '10px' }}>
<p>Processing mode:</p>
<label>
<input
type="radio"
value="OCR"
checked={mode === 'OCR'}
onChange={(e) => setMode(e.target.value)}
/> OCR
</label>
<label style={{ marginLeft: '20px' }}>
<input
type="radio"
value="OCR + AI"
checked={mode === 'OCR + AI'}
onChange={(e) => setMode(e.target.value)}
/> OCR + AI
</label>
<label style={{ marginLeft: '20px' }}>
<input
type="radio"
value="AI"
checked={mode === 'AI'}
onChange={(e) => setMode(e.target.value)}
/> AI
</label>
</div>
<div className="prompt-selector" style={{ marginBottom: '10px' }}>
<label>
{mode === "OCR" ? "Prompt not required for OCR mode" : "Select Prompt:"}
<select
value={promptKey}
onChange={(e) => setPromptKey(e.target.value)}
style={{ marginLeft: '10px' }}
disabled={mode === "OCR"}
>
<option value="">-- Select Prompt --</option>
{Object.keys(availablePrompts).map(key => (
<option key={key} value={key}>{key}</option>
))}
</select>
</label>
</div>
<button type="submit">Upload and process</button>
{jobId && (
<button type="button" onClick={handleStop} style={{ marginLeft: '10px' }}>
Stop Process
</button>
)}
</form>
{jobId && (
<div>
<ProgressBar progress={uploadProgress} />
<p>{message}</p>
</div>
)}
{!jobId && <p>{message}</p>}
</div>
);
}
export default FileUpload;

View file

@ -0,0 +1,17 @@
// frontend/src/components/ModelSelector.js
import React from 'react';
function ModelSelector({ models, selectedModel, setSelectedModel }) {
return (
<label style={{ marginLeft: '10px' }}>
Model:
<select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} style={{ marginLeft: '10px' }}>
{models.map((model, index) => (
<option key={index} value={model}>{model}</option>
))}
</select>
</label>
);
}
export default ModelSelector;

View file

@ -0,0 +1,66 @@
// frontend/src/components/Notifications.js
import React, { useState } from 'react';
function Notifications({ notifications, onClear }) {
const [isOpen, setIsOpen] = useState(false);
const toggleOpen = () => setIsOpen(!isOpen);
const unseenCount = notifications.length;
return (
<div style={{ position: 'fixed', bottom: '10px', right: '10px' }}>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={toggleOpen}
style={{ fontSize: '24px', background: 'none', border: 'none', cursor: 'pointer' }}
>
🔔
{unseenCount > 0 && (
<span style={{
position: 'absolute',
top: '-5px',
right: '-5px',
background: 'red',
color: 'white',
borderRadius: '50%',
padding: '2px 6px',
fontSize: '12px'
}}>
{unseenCount}
</span>
)}
</button>
</div>
{isOpen && (
<div style={{
position: 'absolute',
right: 0,
bottom: '40px',
width: '300px',
maxHeight: '400px',
overflowY: 'auto',
border: '1px solid #ccc',
borderRadius: '8px',
padding: '10px',
backgroundColor: '#fff',
boxShadow: '0 2px 5px rgba(0,0,0,0.3)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h4 style={{ margin: 0 }}>Notifications</h4>
<button onClick={onClear} style={{ fontSize: '12px', padding: '2px 5px' }}>Clear</button>
</div>
{notifications.length === 0 ? (
<p>No notifications.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{notifications.map((note, index) => (
<li key={index} style={{ marginBottom: '5px' }}>{note}</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
export default Notifications;

View file

@ -0,0 +1,37 @@
// frontend/src/components/ProgressBar.js
import React from 'react';
function ProgressBar({ progress }) {
const containerStyle = {
height: '20px',
width: '100%',
backgroundColor: '#e0e0de',
borderRadius: '50px',
margin: '10px 0'
};
const fillerStyle = {
height: '100%',
width: `${progress}%`,
backgroundColor: progress === 100 ? 'green' : '#76c7c0',
borderRadius: 'inherit',
textAlign: 'right',
transition: 'width 0.5s ease-in-out'
};
const labelStyle = {
padding: '5px',
color: 'white',
fontWeight: 'bold'
};
return (
<div style={containerStyle}>
<div style={fillerStyle}>
<span style={labelStyle}>{`${progress}%`}</span>
</div>
</div>
);
}
export default ProgressBar;

View file

@ -0,0 +1,68 @@
// frontend/src/components/TxtToPdf.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const API_URL = '/api';
function TxtToPdf() {
const [txtFiles, setTxtFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState('');
const [message, setMessage] = useState('');
const [pdfFile, setPdfFile] = useState('');
useEffect(() => {
// Obtener la lista de archivos y filtrar solo los .txt
axios.get(`${API_URL}/files`)
.then(response => {
const files = response.data.files.filter(file => file.toLowerCase().endsWith('.txt'));
setTxtFiles(files);
})
.catch(err => console.error(err));
}, []);
const handleConversion = () => {
if (!selectedFile) {
setMessage("⚠️ Please select a TXT file.");
return;
}
axios.post(`${API_URL}/txttopdf`, { filename: selectedFile })
.then(response => {
setMessage(response.data.message);
setPdfFile(response.data.pdf_file);
})
.catch(err => {
setMessage("❌ Error converting TXT to PDF.");
console.error(err);
});
};
return (
<div style={{ textAlign: 'left', maxWidth: '600px', margin: '0 auto' }}>
<h2>TXT to PDF</h2>
<div style={{ marginBottom: '10px' }}>
<label>
Select TXT File:
<select
value={selectedFile}
onChange={(e) => setSelectedFile(e.target.value)}
style={{ marginLeft: '10px' }}
>
<option value="">-- Select TXT File --</option>
{txtFiles.map((file, index) => (
<option key={index} value={file}>{file}</option>
))}
</select>
</label>
</div>
<button onClick={handleConversion}>Convert to PDF</button>
{message && <p>{message}</p>}
{pdfFile && (
<p>
Download PDF: <a href={`${API_URL}/files/${pdfFile}`} download>{pdfFile}</a>
</p>
)}
</div>
);
}
export default TxtToPdf;

11
frontend/src/index.js Normal file
View file

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './App.css';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);