diff --git a/html2latex.py b/html2latex.py new file mode 100644 index 0000000..cfe1a2f --- /dev/null +++ b/html2latex.py @@ -0,0 +1,785 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Convertisseur QCM HTML vers LaTeX pour auto-multiple-choice +Gère les extraits de code Python avec minted +""" + +import re +import os +from bs4 import BeautifulSoup +from pathlib import Path +import html + +class QCMConverter: + def __init__(self, output_dir="codes", debug=False, layout_threshold=50, force_layout=None, use_multicol=False, multicol_columns=2, use_csv=False, element_group="general"): + self.output_dir = Path(output_dir) + self.output_dir.mkdir(exist_ok=True) + self.code_counter = 0 + self.debug = debug + self.layout_threshold = layout_threshold + self.force_layout = force_layout # 'horizontal', 'vertical', ou None + self.use_multicol = use_multicol + self.multicol_columns = multicol_columns + self.use_csv = use_csv + self.element_group = element_group + + def escape_latex_chars(self, text): + """Échappe les caractères spéciaux LaTeX""" + # Ordre important : & avant les autres car on utilise \& + replacements = [ + ('\\', '\\textbackslash{}'), # \ doit être en premier + ('&', '\\&'), + ('%', '\\%'), + ('$', '\\$'), + ('#', '\\#'), + ('^', '\\textasciicircum{}'), + ('_', '\\_'), + ('{', '\\{'), + ('}', '\\}'), + ('~', '\\textasciitilde{}'), + ] + + for char, replacement in replacements: + text = text.replace(char, replacement) + + return text + + def clean_html_tags(self, text): + """Supprime les balises HTML et décode les entités""" + # Supprimer les balises HTML + text = re.sub(r'<[^>]+>', '', text) + # Décoder les entités HTML + text = html.unescape(text) + # Nettoyer les espaces multiples et les sauts de ligne + text = re.sub(r'\s+', ' ', text).strip() + # Échapper les caractères spéciaux LaTeX + text = self.escape_latex_chars(text) + return text + + def format_code_with_line_breaks(self, code, max_line_length=60): + """Formate le code en ajoutant des sauts de ligne si les lignes sont trop longues""" + lines = code.split('\n') + formatted_lines = [] + + for line in lines: + # Si la ligne est plus courte que la limite, la garder telle quelle + if len(line) <= max_line_length: + formatted_lines.append(line) + else: + # Essayer de couper intelligemment + current_line = line + while len(current_line) > max_line_length: + # Points de coupure préférés en Python + break_points = [', ', ' = ', ' + ', ' - ', ' * ', ' / ', ' and ', ' or ', ' if ', ' for ', ' in '] + + best_break = -1 + for bp in break_points: + # Chercher le dernier point de coupure avant la limite + pos = current_line[:max_line_length].rfind(bp) + if pos > best_break and pos > max_line_length // 2: # Au moins à mi-chemin + best_break = pos + len(bp) + + if best_break > 0: + # Couper au meilleur point trouvé + formatted_lines.append(current_line[:best_break].rstrip()) + current_line = ' ' + current_line[best_break:].lstrip() # Indenter la suite + else: + # Pas de bon point de coupure, couper brutalement + formatted_lines.append(current_line[:max_line_length]) + current_line = ' ' + current_line[max_line_length:] # Indenter la suite + + # Ajouter le reste de la ligne + if current_line.strip(): + formatted_lines.append(current_line) + + return '\n'.join(formatted_lines) + + def extract_code_from_pre(self, pre_element): + """Extrait le code Python d'un élément
"""
+ code_element = pre_element.find('code')
+ if not code_element:
+ return ""
+
+ # Extraire le texte en préservant la structure
+ code_lines = []
+ for line in code_element.get_text().split('\n'):
+ # Supprimer les espaces en début de ligne de façon cohérente
+ line = line.rstrip()
+ code_lines.append(line)
+
+ # Supprimer les lignes vides au début et à la fin
+ while code_lines and not code_lines[0].strip():
+ code_lines.pop(0)
+ while code_lines and not code_lines[-1].strip():
+ code_lines.pop()
+
+ raw_code = '\n'.join(code_lines)
+
+ # Formater le code avec des sauts de ligne
+ formatted_code = self.format_code_with_line_breaks(raw_code)
+
+ return formatted_code
+
+ def save_code_to_file(self, code, filename):
+ """Sauvegarde le code Python dans un fichier"""
+ filepath = self.output_dir / filename
+ with open(filepath, 'w', encoding='utf-8') as f:
+ f.write(code)
+ return filepath
+
+ def process_code_blocks(self, element):
+ """Traite les blocs de code et retourne le texte avec les références minted"""
+ result_parts = []
+
+ for child in element.children:
+ if child.name == 'pre':
+ # C'est un bloc de code
+ code = self.extract_code_from_pre(child)
+ if code.strip():
+ self.code_counter += 1
+ filename = f"code_{self.code_counter:03d}.py"
+ self.save_code_to_file(code, filename)
+ result_parts.append(f"\\inputminted[xleftmargin=20pt]{{python}}{{{self.output_dir}/{filename}}}")
+ elif child.name:
+ # Autre balise HTML
+ result_parts.append(self.clean_html_tags(str(child)))
+ else:
+ # Texte simple
+ result_parts.append(str(child).strip())
+
+ return '\n\n'.join(part for part in result_parts if part.strip())
+
+ def extract_inline_code(self, text):
+ """Extrait et formate le code Python inline"""
+ # D'abord échapper les caractères LaTeX normaux
+ text = self.escape_latex_chars(text)
+
+ # Ensuite traiter le code inline (après échappement pour éviter les conflits)
+ # Recherche de code Python simple (variables, méthodes, etc.)
+ patterns = [
+ # Méthodes avec parenthèses
+ (r'([a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*\([^)]*\))', r'\\texttt{\1}'),
+ # Indexation avec crochets
+ (r'([a-zA-Z_][a-zA-Z0-9_]*\[[^\]]+\])', r'\\texttt{\1}'),
+ # Listes littérales
+ (r'(\[[^\]]*\])', r'\\texttt{\1}'),
+ ]
+
+ for pattern, replacement in patterns:
+ text = re.sub(pattern, replacement, text)
+
+ return text
+
+ def parse_question(self, question_html):
+ """Parse une question HTML et retourne les données structurées"""
+ soup = BeautifulSoup(question_html, 'html.parser')
+
+ # Extraire le numéro de question
+ question_match = re.search(r'Q(\d+)', question_html)
+ if not question_match:
+ return None
+
+ question_num = question_match.group(1)
+
+ # Vérifier s'il y a plusieurs questions dans ce HTML
+ all_question_matches = list(re.finditer(r'Q(\d+)\s*-', question_html))
+ if len(all_question_matches) > 1:
+ # Il y a plusieurs questions, extraire seulement la première
+ first_q_start = all_question_matches[0].start()
+ second_q_start = all_question_matches[1].start()
+
+ # Chercher le début du tag qui contient la première question
+ p_start = question_html.rfind('
', 0, first_q_start + 50)
+ if p_start != -1:
+ first_q_start = p_start
+
+ question_html = question_html[first_q_start:second_q_start]
+ soup = BeautifulSoup(question_html, 'html.parser')
+
+ if self.debug:
+ print(f"DEBUG Q{question_num} - Question tronquée (multiple détectée) de {first_q_start} à {second_q_start}")
+ print(f"DEBUG Q{question_num} - HTML tronqué: {question_html[:150]}...")
+
+ # Séparer le HTML en section question et section réponses
+ responses_section_start = question_html.find("Réponses :")
+ if responses_section_start != -1:
+ question_html_part = question_html[:responses_section_start]
+ responses_html_part = question_html[responses_section_start:]
+ else:
+ question_html_part = question_html
+ responses_html_part = ""
+
+ # Parser la section question de manière séquentielle
+ question_soup = BeautifulSoup(question_html_part, 'html.parser')
+ question_parts = []
+
+ # Si le HTML est mal formé (pas de
d'ouverture), le réparer
+ if not question_html_part.strip().startswith('<'):
+ question_html_part = '
' + question_html_part
+ question_soup = BeautifulSoup(question_html_part, 'html.parser')
+
+ # Traiter séquentiellement tous les éléments de la question
+ for element in question_soup.find_all(['p', 'pre']):
+ if element.name == 'p':
+ text = element.get_text().strip()
+ if f'Q{question_num}' in text:
+ # Texte principal de la question (sans le numéro)
+ # Trouver la position de Q{num} et prendre le texte après
+ q_pos = text.find(f'Q{question_num}')
+ if q_pos != -1:
+ clean_text = text[q_pos + len(f'Q{question_num}'):].strip(' -').strip()
+ clean_text = self.clean_html_tags(clean_text)
+ if clean_text:
+ question_parts.append(clean_text)
+ elif text and text != "Réponses :" and not re.match(r'^Q\d+\s*-', text):
+ # Texte additionnel de la question (éviter les questions parasites)
+ clean_text = self.clean_html_tags(text)
+ if clean_text:
+ question_parts.append(clean_text)
+ elif element.name == 'pre':
+ # Bloc de code dans la question
+ code = self.extract_code_from_pre(element)
+ if code.strip():
+ self.code_counter += 1
+ filename = f"q{question_num}_{self.code_counter}.py"
+ self.save_code_to_file(code, filename)
+ question_parts.append(f"\\inputminted[xleftmargin=20pt]{{python}}{{{self.output_dir}/{filename}}}")
+
+ # Parser les réponses de manière plus intelligente
+ responses = []
+ if responses_html_part:
+ responses_soup = BeautifulSoup(responses_html_part, 'html.parser')
+
+ # Analyser séquentiellement tous les éléments après "Réponses :"
+ response_elements = responses_soup.find_all(['p', 'pre'])
+ current_response = None
+
+ for element in response_elements:
+ if element.name == 'p':
+ text = element.get_text().strip()
+
+ # Vérifier si c'est le début d'une nouvelle question (Q[0-9]+)
+ if re.match(r'^Q\d+\s*-', text):
+ if self.debug:
+ print(f"DEBUG Q{question_num} - Nouvelle question détectée dans les réponses: {text[:50]}")
+ break # Arrêter le parsing des réponses
+
+ if self.debug:
+ print(f"DEBUG Q{question_num} - Element P: {repr(text[:100])}")
+
+ # Vérifier si c'est une nouvelle réponse (A-, B-, C-, D-)
+ response_match = re.match(r'^(>|>)?([A-D])-\s*(.*)', text)
+ if response_match:
+ # Sauvegarder la réponse précédente si elle existe
+ if current_response is not None:
+ responses.append(current_response)
+
+ # Commencer une nouvelle réponse
+ prefix = response_match.group(1) or ""
+ letter = response_match.group(2)
+ response_text = response_match.group(3).strip()
+
+ # Nettoyer le texte
+ if response_text:
+ response_text = self.clean_html_tags(response_text)
+
+ # Vérifier si c'est la bonne réponse
+ is_correct = element.find('span', class_='ok') is not None
+
+ current_response = {
+ 'letter': letter,
+ 'text': response_text,
+ 'correct': is_correct
+ }
+
+ if self.debug:
+ print(f"DEBUG Q{question_num} - Nouvelle réponse {letter}: '{response_text}' (correct: {is_correct})")
+
+ elif text == "Réponses :" or not text.strip():
+ # Ignorer les titres et paragraphes vides
+ continue
+ else:
+ # Texte additionnel pour la réponse actuelle
+ if current_response is not None and text:
+ additional_text = self.clean_html_tags(text)
+ if current_response['text']:
+ current_response['text'] += " " + additional_text
+ else:
+ current_response['text'] = additional_text
+
+ elif element.name == 'pre':
+ # Bloc de code pour la réponse actuelle
+ if current_response is not None:
+ code = self.extract_code_from_pre(element)
+ if code.strip():
+ self.code_counter += 1
+ filename = f"r{question_num}_{current_response['letter'].lower()}.py"
+ self.save_code_to_file(code, filename)
+ code_block = f"\\inputminted[xleftmargin=20pt]{{python}}{{{self.output_dir}/{filename}}}"
+
+ if current_response['text']:
+ current_response['text'] += "\n\n" + code_block
+ else:
+ current_response['text'] = code_block
+
+ if self.debug:
+ print(f"DEBUG Q{question_num} - Code ajouté à réponse {current_response['letter']}")
+
+ # Ajouter la dernière réponse
+ if current_response is not None:
+ responses.append(current_response)
+
+ # Vérification de sécurité : chercher des bonnes réponses manquées
+ if not any(resp['correct'] for resp in responses):
+ # Chercher dans le HTML brut des patterns de bonne réponse
+ if '' in responses_html_part:
+ # Chercher quel élément contient le span.ok
+ ok_elements = responses_soup.find_all('span', class_='ok')
+ for ok_element in ok_elements:
+ parent_text = ok_element.parent.get_text() if ok_element.parent else ""
+
+ # Déterminer quelle lettre correspond
+ for letter in ['A', 'B', 'C', 'D']:
+ if f'{letter}-' in parent_text or f'>{letter}-' in parent_text or f'>{letter}-' in parent_text:
+ # Marquer cette réponse comme correcte
+ for resp in responses:
+ if resp['letter'] == letter:
+ resp['correct'] = True
+ if self.debug:
+ print(f"DEBUG Q{question_num} - Correction: Réponse {letter} marquée comme correcte")
+ break
+ break
+
+ # Convertir en format final (sans la lettre)
+ final_responses = []
+ for resp in responses:
+ final_responses.append({
+ 'text': resp['text'],
+ 'correct': resp['correct']
+ })
+ responses = final_responses
+
+ # S'assurer qu'on a exactement 4 réponses
+ while len(responses) < 4:
+ responses.append({
+ 'text': '',
+ 'correct': False
+ })
+
+ # Limiter à 4 réponses maximum
+ responses = responses[:4]
+
+ if self.debug:
+ print(f"DEBUG Q{question_num} - HTML partie question: {question_html_part[:200]}...")
+ print(f"DEBUG Q{question_num} - HTML partie réponses: {responses_html_part[:200]}...")
+ print(f"DEBUG Q{question_num} - Question finale: {len(question_parts)} parties")
+ print(f"DEBUG Q{question_num} - Parties de question: {[part[:50] + '...' if len(part) > 50 else part for part in question_parts]}")
+ print(f"DEBUG Q{question_num} - Réponses finales: {len(responses)} réponses")
+ for i, resp in enumerate(responses):
+ correct_marker = "✓" if resp['correct'] else "✗"
+ text_preview = resp['text'][:80] + ('...' if len(resp['text']) > 80 else '')
+ print(f"DEBUG Q{question_num} - Réponse {['A','B','C','D'][i]}: {correct_marker} {text_preview}")
+
+ return {
+ 'number': question_num,
+ 'text': '\n\n'.join(question_parts),
+ 'responses': responses
+ }
+
+ def get_latex_header(self):
+ """Retourne l'en-tête LaTeX complet pour AMC"""
+ return """\\documentclass[12pt,a4paper]{article}
+
+\\usepackage{csvsimple}%
+
+\\usepackage[francais,bloc]{automultiplechoice}
+
+\\usepackage{minted}
+%\\usepackage{ulem}
+\\usepackage{multicol}
+\\let\\multicolmulticols\\multicols
+\\let\\endmulticolmulticols\\endmulticols
+
+\\RenewDocumentEnvironment{multicols}{mO{}}
+ {%
+ \\ifnum#1=1
+ #2%
+ \\else % More than 1 column
+ \\multicolmulticols{#1}[#2]
+ \\fi
+ }
+ {%
+ \\ifnum#1=1
+ \\else % More than 1 column
+ \\endmulticolmulticols
+ \\fi
+ }
+
+
+\\usepackage{xcolor}
+\\definecolor{LightGray}{gray}{0.9}
+
+% Définition des commandes AMC pour éviter les erreurs
+\\def\\nom{NOM}
+\\def\\prenom{PRENOM}
+\\def\\id{NUMERO}
+
+\\newcommand{\\sujet}{
+\\exemplaire{1}{%
+
+%%% debut de l'en-tête des copies :
+\\begin{center}
+\\noindent{}\\fbox{\\vspace*{3mm}
+ \\Large\\bf\\nom{}~\\prenom{}\\normalsize{}%
+ \\vspace*{3mm}
+ }
+\\end{center}
+
+\\noindent{\\bf QCM \\hfill TEST}
+
+\\vspace*{.5cm}
+\\begin{minipage}{.4\\linewidth}
+ \\centering\\large\\bf Test\\\\ Examen du 01/01/2008
+\\end{minipage}
+
+\\begin{center}\\em
+Durée : 10 minutes.
+
+ Aucun document n'est autorisé.
+ L'usage de la calculatrice est interdit.
+
+ Les questions faisant apparaître le symbole \\multiSymbole{} peuvent
+ présenter zéro, une ou plusieurs bonnes réponses. Les autres ont
+ une unique bonne réponse.
+
+ Des points négatifs pourront être affectés à de \\emph{très
+ mauvaises} réponses.
+\\end{center}
+\\vspace{1ex}
+%%% fin de l'en-tête
+
+\\restituegroupe{""" + self.element_group + """}
+
+
+\\AMCassociation{\\id}
+
+%\\AMCaddpagesto{3}
+ }
+}
+
+%%%%§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§§
+
+\\begin{document}
+%%%Options
+\\AMCrandomseed{1237893}
+
+\\def\\AMCformQuestion#1{{\\sc Question #1 :}}
+
+\\setdefaultgroupmode{withoutreplacement}
+%%% Fin Options
+
+%%% groupes
+
+% Questions générées automatiquement"""
+
+ def get_latex_footer(self):
+ """Retourne le pied de page LaTeX pour AMC"""
+ if self.use_csv:
+ return f"""
+
+\\csvreader[head to column names]{{liste.csv}}{{}}{{\\sujet}}
+
+\\end{{document}}"""
+ else:
+ return f"""
+
+\\sujet
+
+\\end{{document}}"""
+
+ def analyze_code_complexity(self, question_text, responses):
+ """Analyse la complexité du code dans la question et les réponses"""
+ # Compter les blocs de code dans la question
+ question_code_blocks = question_text.count('\\inputminted')
+
+ # Détecter les codes longs dans la question (plus de 2 blocs = complexe)
+ has_long_question_code = question_code_blocks > 2
+
+ # Analyser les réponses
+ response_code_blocks = sum(1 for resp in responses if '\\inputminted' in resp['text'])
+ has_code_in_responses = response_code_blocks > 0
+
+ return {
+ 'question_code_blocks': question_code_blocks,
+ 'response_code_blocks': response_code_blocks,
+ 'has_long_question_code': has_long_question_code,
+ 'has_code_in_responses': has_code_in_responses
+ }
+
+ def should_use_horizontal_layout(self, question_text, responses):
+ """Détermine si on doit utiliser un layout horizontal ou vertical"""
+ # Si un layout est forcé, l'utiliser
+ if self.force_layout == 'horizontal':
+ return True
+ elif self.force_layout == 'vertical':
+ return False
+
+ # Analyser la complexité du code
+ code_analysis = self.analyze_code_complexity(question_text, responses)
+
+ # Si la question a beaucoup de code, forcer vertical pour les réponses
+ if code_analysis['has_long_question_code']:
+ return False
+
+ # Si les réponses contiennent du code, forcer vertical
+ if code_analysis['has_code_in_responses']:
+ return False
+
+ # Vérifier si toutes les réponses sont courtes
+ all_short = all(len(resp['text']) <= self.layout_threshold for resp in responses)
+
+ # Vérifier si aucune réponse ne contient de code multilignes
+ no_multiline_code = all('\\inputminted' not in resp['text'] and '\n' not in resp['text']
+ for resp in responses)
+
+ return all_short and no_multiline_code
+
+ def should_use_multicol(self, question_text, responses):
+ """Détermine si on doit utiliser multicol pour cette question"""
+ if not self.use_multicol:
+ return False
+
+ # Analyser la complexité du code
+ code_analysis = self.analyze_code_complexity(question_text, responses)
+
+ # Ne pas utiliser multicol si la question a beaucoup de code
+ if code_analysis['has_long_question_code']:
+ return False
+
+ # Ne jamais utiliser multicol s'il y a du code dans les réponses
+ if code_analysis['has_code_in_responses']:
+ return False
+
+ # Calculer la longueur moyenne des réponses
+ valid_responses = [resp for resp in responses if resp['text'].strip()]
+ if not valid_responses:
+ return False
+
+ avg_length = sum(len(resp['text']) for resp in valid_responses) / len(valid_responses)
+ max_length = max(len(resp['text']) for resp in valid_responses)
+
+ # Utiliser multicol seulement pour les réponses très courtes
+ return avg_length <= 25 and max_length <= 35
+
+ def determine_multicol_columns(self, responses):
+ """Détermine le nombre optimal de colonnes pour multicol"""
+ valid_responses = [resp for resp in responses if resp['text'].strip()]
+ if not valid_responses:
+ return 2
+
+ avg_length = sum(len(resp['text']) for resp in valid_responses) / len(valid_responses)
+ max_length = max(len(resp['text']) for resp in valid_responses)
+
+ # Réponses très courtes -> 4 colonnes
+ if avg_length <= 15 and max_length <= 20 and len(valid_responses) == 4:
+ return 4
+ # Réponses courtes -> 2 colonnes
+ elif avg_length <= 25 and max_length <= 35:
+ return 2
+ else:
+ return 2 # fallback
+
+ def generate_latex(self, question_data):
+ """Génère le code LaTeX pour une question"""
+ latex_parts = []
+
+ # Encapsuler chaque question dans son propre element (même groupe pour toutes)
+ latex_parts.append(f"\\element{{{self.element_group}}}{{")
+ latex_parts.append(f"\\begin{{question}}{{q{question_data['number']}}}")
+ latex_parts.append("")
+ latex_parts.append(question_data['text'])
+ latex_parts.append("")
+
+ # Choisir le layout selon la longueur des réponses et la complexité du code
+ use_horizontal = self.should_use_horizontal_layout(question_data['text'], question_data['responses'])
+ use_multicol = self.should_use_multicol(question_data['text'], question_data['responses'])
+
+ # Filtrer les réponses vides
+ valid_responses = [resp for resp in question_data['responses'] if resp['text'].strip()]
+
+ if use_multicol and len(valid_responses) > 0:
+ # Déterminer le nombre optimal de colonnes
+ optimal_columns = self.determine_multicol_columns(valid_responses)
+ layout_env = "reponses"
+ latex_parts.append(f" \\begin{{multicols}}{{{optimal_columns}}}")
+ latex_parts.append(f" \\begin{{{layout_env}}}")
+ else:
+ layout_env = "reponseshoriz" if use_horizontal else "reponses"
+ latex_parts.append(f" \\begin{{{layout_env}}}")
+
+ if self.debug:
+ code_analysis = self.analyze_code_complexity(question_data['text'], question_data['responses'])
+ total_length = sum(len(resp['text']) for resp in valid_responses)
+ avg_length = total_length / len(valid_responses) if valid_responses else 0
+
+ layout_info = f"{layout_env}"
+ if use_multicol:
+ optimal_columns = self.determine_multicol_columns(valid_responses)
+ layout_info += f" + multicol({optimal_columns})"
+ if self.force_layout:
+ layout_info += f" (forcé: {self.force_layout})"
+ if code_analysis['has_long_question_code']:
+ layout_info += " (vertical: code long dans question)"
+ elif code_analysis['has_code_in_responses']:
+ layout_info += " (vertical: code dans réponses)"
+
+ print(f"DEBUG Q{question_data['number']} - Layout: {layout_info}")
+ print(f"DEBUG Q{question_data['number']} - Code: {code_analysis['question_code_blocks']} blocs question, {code_analysis['response_code_blocks']} blocs réponses")
+ print(f"DEBUG Q{question_data['number']} - Réponses: {len(valid_responses)} valides, longueur moy: {avg_length:.1f}")
+
+ for resp in valid_responses:
+ command = "\\bonne" if resp['correct'] else "\\mauvaise"
+ latex_parts.append(f" {command}{{{resp['text']}}}")
+
+ if use_multicol and len(valid_responses) > 0:
+ latex_parts.append(f" \\end{{{layout_env}}}")
+ latex_parts.append(f" \\end{{multicols}}")
+ else:
+ latex_parts.append(f" \\end{{{layout_env}}}")
+
+ latex_parts.append("\\end{question}")
+ latex_parts.append("}") # Fermer l'element
+ latex_parts.append("")
+
+ return '\n'.join(latex_parts)
+
+ def parse_html_file(self, html_content):
+ """Parse un fichier HTML complet et extrait toutes les questions"""
+ # D'abord essayer de séparer par
+ if '
' in html_content:
+ questions_html = html_content.split('
')
+ else:
+ # Pas de
, chercher les questions par pattern Q[0-9]+
+ questions_html = []
+
+ # Chercher tous les patterns Q[numéro] dans le texte
+ question_pattern = r'\s*Q(\d+)\s*-'
+ matches = list(re.finditer(question_pattern, html_content))
+
+ if matches:
+ for i, match in enumerate(matches):
+ start = match.start()
+ # Fin = début de la question suivante ou fin du document
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(html_content)
+
+ question_html = html_content[start:end]
+ questions_html.append(question_html)
+
+ if self.debug:
+ question_num = match.group(1)
+ print(f"DEBUG - Question Q{question_num} trouvée de {start} à {end}")
+ else:
+ # Fallback : traiter tout comme une seule question
+ questions_html = [html_content]
+
+ questions_data = []
+ for q_html in questions_html:
+ if 'Q' in q_html and '- ' in q_html: # Vérifier qu'il y a bien une question
+ question_data = self.parse_question(q_html)
+ if question_data:
+ questions_data.append(question_data)
+
+ return questions_data
+
+ def convert_file(self, input_file, output_file, full_document=False):
+ """Convertit un fichier HTML en LaTeX"""
+ with open(input_file, 'r', encoding='utf-8') as f:
+ html_content = f.read()
+
+ questions = self.parse_html_file(html_content)
+
+ latex_content = []
+
+ if full_document:
+ latex_content.append(self.get_latex_header())
+ else:
+ latex_content.append("% Questions générées automatiquement")
+
+ latex_content.append("")
+
+ for question in questions:
+ latex_content.append(self.generate_latex(question))
+
+ if full_document:
+ latex_content.append(self.get_latex_footer())
+
+ with open(output_file, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(latex_content))
+
+ print(f"Conversion terminée !")
+ print(f"- {len(questions)} questions converties")
+ print(f"- {self.code_counter} fichiers de code créés dans {self.output_dir}/")
+ print(f"- Fichier LaTeX généré : {output_file}")
+ if full_document:
+ print(f"- Document LaTeX complet généré (prêt à compiler)")
+ if self.use_csv:
+ print(f"- Mode CSV activé (nécessite liste.csv)")
+ else:
+ print(f"- Mode simple activé (\\sujet)")
+ print(f"- Groupe d'éléments : {self.element_group}")
+ if self.force_layout:
+ print(f"- Layout forcé : {self.force_layout}")
+ if self.use_multicol:
+ print(f"- Multicol activé : {self.multicol_columns} colonnes pour réponses courtes")
+ if self.debug:
+ print(f"- Mode debug était activé")
+
+def main():
+ """Fonction principale"""
+ import argparse
+
+ parser = argparse.ArgumentParser(description='Convertit un QCM HTML vers LaTeX AMC')
+ parser.add_argument('input_file', help='Fichier HTML d\'entrée')
+ parser.add_argument('-o', '--output', default='qcm.tex', help='Fichier LaTeX de sortie')
+ parser.add_argument('-c', '--codes-dir', default='codes', help='Répertoire pour les fichiers de code')
+ parser.add_argument('-d', '--debug', action='store_true', help='Active le mode debug avec informations détaillées')
+ parser.add_argument('-f', '--full-document', action='store_true', help='Génère un document LaTeX complet (avec documentclass, packages et structure AMC)')
+ parser.add_argument('--csv', action='store_true', help='Utilise \\csvreader au lieu de \\sujet (nécessite liste.csv)')
+ parser.add_argument('-g', '--element-group', default='general', help='Nom du groupe d\'éléments pour AMC (défaut: general)')
+
+ # Options de layout
+ layout_group = parser.add_argument_group('Options de mise en page')
+ layout_group.add_argument('--layout-threshold', type=int, default=50, help='Seuil de longueur pour choisir le layout horizontal (défaut: 50)')
+ layout_group.add_argument('--force-horizontal', action='store_true', help='Force le layout horizontal pour toutes les réponses')
+ layout_group.add_argument('--force-vertical', action='store_true', help='Force le layout vertical pour toutes les réponses')
+ layout_group.add_argument('--multicol', action='store_true', help='Active l\'utilisation de multicol pour les réponses très courtes')
+ layout_group.add_argument('--multicol-columns', type=int, default=2, help='Nombre de colonnes pour multicol (défaut: 2)')
+
+ args = parser.parse_args()
+
+ # Vérifier les conflits d'options
+ if args.force_horizontal and args.force_vertical:
+ parser.error("--force-horizontal et --force-vertical ne peuvent pas être utilisés ensemble")
+
+ # Déterminer le layout forcé
+ force_layout = None
+ if args.force_horizontal:
+ force_layout = 'horizontal'
+ elif args.force_vertical:
+ force_layout = 'vertical'
+
+ converter = QCMConverter(
+ output_dir=args.codes_dir,
+ debug=args.debug,
+ layout_threshold=args.layout_threshold,
+ force_layout=force_layout,
+ use_multicol=args.multicol,
+ multicol_columns=args.multicol_columns,
+ use_csv=args.csv,
+ element_group=args.element_group
+ )
+ converter.convert_file(args.input_file, args.output, full_document=args.full_document)
+
+if __name__ == '__main__':
+ main()
\ No newline at end of file