import os import uuid import base64 import re import threading import time from typing import List, Dict import logging import tempfile import shutil import json import asyncio import hashlib from openai import AsyncOpenAI from readability import Document import instructor from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks from fastapi.responses import FileResponse, JSONResponse import pypandoc import fitz # PyMuPDF from bs4 import BeautifulSoup, Comment try: from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE except ImportError: pass try: import textract except ImportError: pass logging.basicConfig(level=logging.DEBUG) app = FastAPI() client = instructor.apatch(AsyncOpenAI()) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) JOBS_DIR = os.path.join(tempfile.gettempdir(), 'jobs') if not os.path.exists(JOBS_DIR): os.makedirs(JOBS_DIR) FORMAT_MAP = { '.odt': 'odt', '.pdf': 'pdf', '.docx': 'docx', '.html': 'html', '.htm': 'html', '.md': 'markdown', '.txt': 'markdown', '.rtf': 'rtf', '.epub': 'epub', '.xml': 'xml', '.org': 'org', '.commonmark': 'commonmark', '.cm': 'commonmark', '.wiki': 'mediawiki', '.opml': 'opml' } ALLOWED_EXTENSIONS_FOR_ACCESSIBILITY = list(FORMAT_MAP.keys()) + ['.doc', '.ppt', '.pptx'] def get_pandoc_format(extension: str) -> str: return FORMAT_MAP.get(extension, 'auto') def update_job_status(job_id: str, status: str, message: str = '', result_file: str = None): job_dir = os.path.join(JOBS_DIR, job_id) status_file = os.path.join(job_dir, 'status.json') status_data = { 'status': status, 'message': message, 'updated_at': time.time() } if result_file: status_data['result_file'] = result_file with open(status_file, 'w') as f: json.dump(status_data, f) def get_job_status(job_id: str): job_dir = os.path.join(JOBS_DIR, job_id) status_file = os.path.join(job_dir, 'status.json') if not os.path.exists(status_file): return None with open(status_file, 'r') as f: status_data = json.load(f) return status_data def delete_files_after_delay(file_paths: List[str], delay: int = 6000): def delayed_delete(): time.sleep(delay) for file_path in file_paths: try: if os.path.exists(file_path): os.remove(file_path) logging.debug(f"Fichier temporaire supprimé après délai : {file_path}") except Exception as e: logging.error(f"Erreur lors de la suppression du fichier {file_path} : {str(e)}") thread = threading.Thread(target=delayed_delete) thread.start() def insert_page_comments_every_15_paragraphs(html_content: str) -> str: soup = BeautifulSoup(html_content, 'html.parser') paragraphs = soup.find_all('p') page_number = 1 for i, p in enumerate(paragraphs, start=1): if i % 15 == 1: comment = soup.new_string(f"") p.insert_before(comment) page_number += 1 return str(soup) def insert_css_into_html(html_content: str) -> str: css_code = """ :root { --font-size-min: 1rem; --font-size-base: 1rem; --font-size-large: 2.5rem; --line-height: 1.5; --font-family: Arial, Calibri, Verdana, sans-serif; --text-color: #1a1a1a; --background-color: #fdfdfd; --link-color: #1a1a1a; --heading-color-primary: Navy; --heading-color-secondary: DarkGreen; --heading-color-tertiary: DarkRed; --heading-color-quaternary: DarkSlateGray; --heading-color-cinq: DarkSlateBlue; --heading-color-six: DarkViolet; } * { font-size: 1rem; } html { font-family: var(--font-family); font-size: var(--font-size-base); line-height: var(--line-height); color: var(--text-color); background-color: var(--background-color); font-size: clamp(var(--font-size-min), 2vw, 1.5rem); } body { margin: 20px auto; max-width: 36em; padding: 2rem; hyphens: auto; overflow-wrap: break-word; text-rendering: optimizeLegibility; font-kerning: normal; text-align: left; } h1 {margin-left: 0; color: var(--heading-color-primary);} h2 {margin-left: 1rem; color: var(--heading-color-secondary);} h3 {margin-left: 2rem; color: var(--heading-color-tertiary);} h4 {margin-left: 3rem; color: var(--heading-color-quaternary);} h5 {margin-left: 4rem; color: var(--heading-color-cinq);} h6 {margin-left: 5rem; color: var(--heading-color-six);} @media (max-width: 600px) { html { font-size: clamp(var(--font-size-min), 4vw, 1.5rem); } body { padding: 1rem; } h1 {font-size: clamp(1.5rem, 6vw, 2.5rem);} h2 {font-size: clamp(1.25rem, 5vw, 2rem);} h3 {font-size: clamp(1.125rem, 4.5vw, 1.75rem);} h4, h5, h6 {font-size: clamp(1rem, 4vw, 1.5rem);} } @media print { body { background-color: transparent; color: black; font-size: 12pt; } p, h2, h3 { orphans: 3; widows: 3; } h2, h3, h4 { page-break-after: avoid; } } p {margin: 1em 0; font-size: 1rem;} a {color: var(--link-color); text-decoration: none;} a:visited {color: var(--link-color);} a:hover, a:focus {text-decoration: underline;} img {max-width: 100%; height: auto;} table { margin: 1em 0; border-collapse: collapse; width: 100%; overflow-x: auto; display: block; font-variant-numeric: lining-nums tabular-nums; } table caption {margin-bottom: 0.75em;} th, td {border: 1px solid #000; padding: 0.5em; text-align: left;} tbody tr:nth-child(odd) {background-color: #f2f2f2;} tbody tr:nth-child(even) {background-color: #ffffff;} blockquote { margin: 1em 0 1em 1.7em; padding-left: 1em; border-left: 2px solid #e6e6e6; color: #606060; } code { font-family: Menlo, Monaco, 'Lucida Console', Consolas, monospace; font-size: 0.85rem; margin: 0; white-space: pre-wrap; } pre { margin: 1em 0; overflow: auto; } pre code { padding: 0; overflow: visible; overflow-wrap: normal; } .sourceCode { background-color: transparent; overflow: visible; } hr { background-color: #1a1a1a; border: none; height: 1px; margin: 1em 0; } span.smallcaps {font-variant: small-caps;} span.underline {text-decoration: underline;} div.column {display: inline-block; vertical-align: top; width: 50%;} .description { background-color: #f0f3ff; padding: 1em; border: 1px solid black; } div.hanging-indent { margin-left: 1.5em; text-indent: -1.5em; } ul.task-list {list-style: none;} .display.math { display: block; text-align: center; margin: 0.5rem auto; } """ final_soup = BeautifulSoup(html_content, 'html.parser') style_tag = final_soup.new_tag('style') style_tag.string = css_code head_tag = final_soup.head if head_tag: head_tag.clear() head_tag.append(style_tag) else: head_tag = final_soup.new_tag('head') head_tag.append(style_tag) final_soup.insert(0, head_tag) final_html = str(final_soup) return final_html def encode_image_from_data_uri(data_uri: str) -> str: try: header, encoded = data_uri.split(',', 1) encoded = ''.join(encoded.split()) return encoded except Exception as e: logging.error(f"Erreur lors de l'encodage de l'image : {str(e)}") return "" def pdf_to_html(input_filename: str) -> str: soup = BeautifulSoup("", 'html.parser') body = soup.body page_number = 1 with fitz.open(input_filename) as doc: for page in doc: page_comment = f"" body.append(BeautifulSoup(page_comment, 'html.parser')) page_html = page.get_text("html") page_fragment = BeautifulSoup(page_html, 'html.parser') body.append(page_fragment) page_number += 1 return str(soup) def convert_with_pandoc(input_filename: str, input_format: str) -> str: if input_format == 'docx': try: output = pypandoc.convert_file( input_filename, 'html', format=input_format, outputfile=None, extra_args=['--self-contained', '--strip-comments', '--quiet'] ) return output except RuntimeError as e: logging.error(f"Pandoc a rencontré une erreur avec --self-contained sur un docx : {str(e)}") raise RuntimeError("Impossible de convertir le docx avec --self-contained. Les images ne peuvent pas être traitées.") elif os.path.splitext(input_filename)[1].lower() == '.ppt': try: output = pypandoc.convert_file( input_filename, 'html', format='auto', outputfile=None, extra_args=['--strip-comments', '--quiet'] ) return output except RuntimeError as e: logging.error(f"Pandoc a rencontré une erreur avec le format 'auto' sur un ppt : {str(e)}, tentative avec 'ppt'.") try: output = pypandoc.convert_file( input_filename, 'html', format='ppt', outputfile=None, extra_args=['--strip-comments', '--quiet'] ) return output except RuntimeError as e: logging.error(f"Pandoc a rencontré une erreur avec le format 'ppt' sur un ppt : {str(e)}.") raise else: try: output = pypandoc.convert_file( input_filename, 'html', format=input_format, outputfile=None, extra_args=['--self-contained', '--strip-comments', '--quiet'] ) return output except RuntimeError as e: logging.error(f"Pandoc a rencontré une erreur : {str(e)}, tentative sans --self-contained.") output = pypandoc.convert_file( input_filename, 'html', format=input_format, outputfile=None, extra_args=['--strip-comments', '--quiet'] ) return output def text_to_html(text: str) -> str: lines = text.split('\n') html_lines = ['

' + line.strip() + '

' for line in lines if line.strip()] return "" + "\n".join(html_lines) + "" def convert_ppt_to_text(input_filename: str) -> str: if 'Presentation' not in globals(): raise HTTPException(status_code=500, detail="La librairie python-pptx n'est pas installée.") prs = Presentation(input_filename) text_content = [] for slide in prs.slides: for shape in slide.shapes: if hasattr(shape, "text"): text_content.append(shape.text) return "\n".join(text_content) def convert_pptx_to_html(input_filename: str) -> str: if 'Presentation' not in globals(): raise HTTPException(status_code=500, detail="La librairie python-pptx n'est pas installée.") prs = Presentation(input_filename) html_content = "" slide_number = 1 for slide in prs.slides: html_content += f"" for shape in slide.shapes: if shape.has_text_frame: text_content = shape.text_frame.text # Basic handling for different text levels - can be improved if shape.is_placeholder: if shape.placeholder_format.idx == 0: # Title html_content += f"

{text_content}

" elif shape.placeholder_format.idx == 1: # Subtitle/Content html_content += f"

{text_content}

" else: html_content += f"

{text_content}

" else: html_content += f"

{text_content}

" elif shape.shape_type == MSO_SHAPE_TYPE.PICTURE: image = shape.image image_bytes = image.blob base64_encoded = base64.b64encode(image_bytes).decode('utf-8') mime_type = image.content_type html_content += f'Slide Image' slide_number += 1 html_content += "" return html_content def convert_doc_to_text(input_filename: str) -> str: if 'textract' not in globals(): raise HTTPException(status_code=500, detail="La librairie textract n'est pas installée.") text = textract.process(input_filename).decode('utf-8', errors='replace') return text def clean_html_file(input_filepath: str, cleaned_output_filepath: str) -> bool: try: with open(input_filepath, 'r', encoding='utf-8') as f: html_content = f.read() doc = Document(html_content) cleaned_html = doc.summary() with open(cleaned_output_filepath, 'w', encoding='utf-8') as f: f.write(cleaned_html) logging.debug("Contenu HTML nettoyé avec readability-lxml.") return True except Exception as e: logging.error(f"Erreur lors du nettoyage du fichier HTML {input_filepath} : {str(e)}") return False from bs4 import Comment async def clean_html_content(html_content: str, image_counter: List[int], images_data: Dict[str, Dict[str, str]]) -> str: soup = BeautifulSoup(html_content, 'html.parser') logging.debug(f"DEBUG CLEAN_HTML: Début de clean_html_content") for tag in soup.find_all(): if 'style' in tag.attrs: del tag['style'] for element in soup.find_all(['header', 'footer']): element.decompose() for span in soup.find_all('span'): span.unwrap() img_tags = soup.find_all('img') logging.debug(f"DEBUG CLEAN_HTML: Nombre de balises trouvées : {len(img_tags)}") if img_tags: if len(img_tags) > 40: logging.warning(f"Number of images ({len(img_tags)}) exceeds 40. Images will be ignored.") for img in img_tags: img.decompose() else: for img in img_tags: src = img.get('src', '') logging.debug(f"DEBUG CLEAN_HTML: Traitement de la balise avec src='{src[:100]}...'") X = image_counter[0] if src.startswith('data:image/'): logging.debug(f"DEBUG CLEAN_HTML: src commence par data:image/") base64_image = encode_image_from_data_uri(src) if base64_image: images_data[f"IMG_{X}"] = { 'base64_image': base64_image } comment_tag = Comment(f"IMG_{X}") img.insert_before(comment_tag) logging.debug(f"DEBUG CLEAN_HTML: Insertion du commentaire avant l'image : {comment_tag}") logging.debug(f"DEBUG CLEAN_HTML: Vérification immédiate du commentaire après insertion : {soup.find(string=comment_tag)}") img.decompose() logging.debug(f"DEBUG CLEAN_HTML: Suppression de la balise img.") image_counter[0] += 1 else: logging.debug(f"DEBUG CLEAN_HTML: Erreur lors de l'encodage base64, suppression de l'image.") img.decompose() else: logging.debug(f"DEBUG CLEAN_HTML: src ne commence PAS par data:image/, suppression de l'image.") img.decompose() else: logging.debug("DEBUG CLEAN_HTML: Aucune balise trouvée dans le contenu HTML.") logging.debug(f"DEBUG CLEAN_HTML: Fin de clean_html_content") return str(soup) def reinsert_images(html_content: str, images_data: Dict[str, Dict[str, str]]) -> str: soup = BeautifulSoup(html_content, 'html.parser') for comment in soup.find_all(string=lambda text: isinstance(text, Comment)): match = re.match(r'IMG_(\d+)', comment) if match: image_number = match.group(1) image_key = f"IMG_{image_number}" if image_key in images_data: img_tag = soup.new_tag('img') img_tag['src'] = f"data:image/jpeg;base64,{images_data[image_key]['base64_image']}" img_tag['alt'] = images_data[image_key].get('description', 'Description indisponible') new_content = soup.new_tag('div', attrs={'class': 'image-block'}) new_content.append(img_tag) p_tag = soup.new_tag('p', attrs={'class': 'description'}) description = images_data[image_key]['description'].replace('\n', ' ').strip() description = re.sub(r'\s+', ' ', description) p_tag.string = f"Image {image_number} : {description}" new_content.append(p_tag) # Ajout d'un journal pour vérifier l'insertion logging.debug(f"Inserting description for {image_key}: {p_tag.get_text(strip=True)}") comment.replace_with(new_content) else: logging.error(f"Données pour {image_key} non trouvées.") return str(soup) def get_context_for_image(html_content: str, image_key: str) -> str: index = html_content.find(f"") if index == -1: return "" start_context = max(0, index - 500) context_snippet = html_content[start_context:index] snippet_soup = BeautifulSoup(context_snippet, 'html.parser') context_text = snippet_soup.get_text(separator=' ', strip=True) return context_text async def get_image_description(base64_image: str, prompt: str) -> str: try: response = await client.chat.completions.create( model="gpt-4o-mini", messages=[ { "role": "user", "content": [ { "type": "text", "text": prompt, }, { "type": "image_url", "image_url": { "url": f"data:image/jpeg;base64,{base64_image}" }, }, ], } ], ) description = response.choices[0].message.content.strip() return description except Exception as e: logging.error(f"Erreur lors de l'appel à l'API OpenAI : {str(e)}") return "Description indisponible." async def rewrite_html_accessible(html_content: str) -> str: prompt = ( "Je vais te donner un fichier HTML, et je voudrais que tu le réécrives pour permettre l'accessibilité à toutes les formes de handicap, tout en **préservant strictement l'ordre du contenu original**.\n" "Commence à analyser le plan du document. Il faut d'abord identifier les titres et comprendre leur logique :\n" "- A priori, les titres qui sont préfixés par une écriture romaine (I, II, III), " "par un nombre (1, 2, 3) ou par une lettre (a, b, c, ou bien A, B, C) doivent être de même niveau." "Idem pour les titres rédigés en majuscules.\n" "- Quand une expression très courte qui ne ressemble pas syntaxiquement à une phrase est présentée sur une seule ligne," "il y a des chances qu'il s'agisse d'un titre : dans ce cas (et si c'est pertinent) traite-la comme telle.\n" "- Au contraire, **une phrase longue ne doit JAMAIS être traitée comme un titre**," "même quand elle est précédée par un numéro ou une lettre." "De même, ne traite jamais comme un titre un ensemble de plusieurs phrases. Je repète : les balises

,

, etc., ne sont destinées qu'à encadrer des expressions relativement courtes, et rien d'autre.\n\n" "Tu ne dois **rien réorganiser**, **ne rien supprimer** et **ne rien ajouter** en termes de structure ou de contenu. " "Ton intervention doit se faire exclusivement sur la **forme** du document : le contenu doit être **intégralement préservé dans le même ordre**, jusqu'à la fin. " "Laisse la balise vide.\n" "IMPORTANT : Tu dois **respecter scrupuleusement l'ordre indiqué par les commentaires HTML de la forme ,** s'ils existent. On doit avoir [...] [...] [...], et ainsi de suite, dans l'ordre exact et sans en oublier un seul. C'est très important ! Ces marqueurs te permettent de t'assurer que la page est bien retranscrite dans le bon ordre. Ne déplace, ne supprime, et ne modifie pas ces commentaires.\n" "Attention, ce document est peut-être issu d'un PDF ou d'un DOCX. Il faut donc être attentif :\n" "- Aux balises

qui suivent immédiatement les marqueurs : il peut s'agir de headers. Pour le savoir, il faut les comparer entre eux pour savoir s'ils sont à peu près similaires.\n" "- Aux balises

qui précèdent immédiatement les marqueurs : il peut s'agir de footers. De même, il faut les comparer entre eux pour savoir s'ils sont à peu près similaires.\n" "Dans tous les cas, il faut supprimer les balises

correspondant aux headers et les footers identifiés. Attention, ces suppressions ne doivent pas affecter les autres éléments.\n" "S'il y a des retours à la ligne injustifiés, il faut rétablir l'intégrité des phrases, et constituer de véritables paragraphes complets. L'ensemble du code doit être inclus entre des balises \n" "Tu donneras la totalité du HTML réécrit, et rien d'autre, ni avant ni après. " "Ne résume jamais les informations, ne réorganise pas le contenu et ne supprime aucune section.\n\n" "Voici tout d'abord les règles à suivre pour avoir un document accessible :\n\n" "1. Limiter l'italique et les soulignements.\n" "2. S'il y a des tableaux, insérer un tiret dans les cellules ne contenant pas d’information, et associer une légende aux tableaux.\n" "3. Pour les titres, utilise absolument les balises h1, h2, h3, h4, h5 et h6. Utilise la balise h1 pour le titre qui a le plus grand niveau.\n\n" "On évite les balises