Files
Site_web/exercices/corriger-exercice.html
gauthier.chombart 21a7359057 Retravaille l'exercice FDS : étiquette libre et corrigé déplacé
- Slide 5 (FT) : reformule la question sur le type de produit en « Où se
  trouve la liste des TP (type de produit) ? »
- Slide 7 : l'utilisateur peut désormais cliquer n'importe quelle zone,
  même incorrecte ; le clic est enregistré et l'exercice avance.
- Slide 7 : « Terminer l'exercice » mène directement à la synthèse ; le
  récapitulatif « l'étiquette annotée » quitte le déroulé des slides.
- Déplace ce récapitulatif (étiquette annotée + check-list réglementaire)
  vers corriger-exercice.html, en corrigé de référence du rendu FDS.
2026-07-21 08:01:12 +02:00

1205 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Corriger un exercice — Certibiocide</title>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../header-style.css"/>
<style>
body {
padding: calc(36px + 70px) 20px 80px;
}
.wrap { max-width: 860px; margin: 0 auto; }
.eyebrow {
font-family: 'Syne', sans-serif;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--amber);
margin-bottom: 6px;
}
h1 {
font-family: 'Syne', sans-serif;
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
margin-bottom: 8px;
}
.lead { color: var(--text-muted); font-size: 14.5px; line-height: 1.6; margin-bottom: 28px; max-width: 620px; }
/* STEP LABEL */
.step-label {
font-family: 'Syne', sans-serif;
font-size: 13px;
font-weight: 700;
color: var(--text);
margin: 0 0 14px;
display: flex;
align-items: center;
gap: 10px;
}
.step-num {
width: 24px; height: 24px;
border-radius: 50%;
background: var(--amber);
color: #fff;
font-size: 12px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
/* EXERCISE SELECTOR */
.exo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 12px;
margin-bottom: 36px;
}
.exo-option { position: relative; }
.exo-option input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.exo-option label {
display: flex;
align-items: center;
gap: 14px;
background: #fff;
border: 1.5px solid var(--border);
border-radius: 14px;
padding: 16px 18px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
height: 100%;
}
.exo-option label:hover { border-color: var(--amber); }
.exo-option input:checked + label {
border-color: var(--amber);
background: var(--amber-light);
box-shadow: 0 4px 16px rgba(186,117,23,0.12);
}
.exo-option input:focus-visible + label { outline: 2px solid var(--amber); outline-offset: 2px; }
.exo-icon {
width: 44px; height: 44px;
border-radius: 12px;
background: var(--amber-light);
display: flex; align-items: center; justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.exo-option input:checked + label .exo-icon { background: #fff; }
.exo-text { flex: 1; min-width: 0; }
.exo-name { display: block; font-family: 'Syne', sans-serif; font-weight: 700; font-size: 14.5px; }
.exo-sub { display: block; font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
.exo-check {
width: 22px; height: 22px;
border-radius: 50%;
border: 1.5px solid var(--border);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: #fff;
font-size: 12px;
transition: background 0.15s, border-color 0.15s;
}
.exo-option input:checked + label .exo-check {
background: var(--amber);
border-color: var(--amber);
}
.exo-option input:checked + label .exo-check::after { content: '✓'; }
/* DROPZONE */
#dropzone {
background: #fff;
border: 2px dashed rgba(186,117,23,0.35);
border-radius: 16px;
padding: 56px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s, opacity 0.2s;
}
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
#dropzone.disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
#filePicker { display: none; }
.btn-pick {
font-family: 'Syne', sans-serif;
display: inline-block;
background: var(--amber);
color: #fff;
font-weight: 700;
font-size: 13px;
padding: 10px 26px;
border-radius: 30px;
cursor: pointer;
border: none;
}
#status { margin-top: 16px; font-size: 13px; min-height: 18px; }
#status.hint { color: var(--text-muted); }
#status.loading { color: var(--amber); }
#status.error { color: var(--coral); }
/* ======================= RÉSULTAT ======================= */
#result { display: none; }
#setup.hidden { display: none; }
.toprow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
margin-bottom: 18px;
flex-wrap: wrap;
}
#fileName { font-size: 12.5px; color: var(--text-muted); }
.btn-reset {
font-family: 'Syne', sans-serif;
background: transparent;
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
}
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
/* BANDEAU PARTICIPANTS */
#participantsBanner {
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
border-radius: 16px;
padding: 20px 26px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 16px;
color: #fff;
flex-wrap: wrap;
}
#participantsBanner .pb-icon {
width: 48px; height: 48px;
border-radius: 12px;
background: rgba(255,255,255,0.2);
display: flex; align-items: center; justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
#participantsBanner .pb-group {
font-family: 'Syne', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
margin-bottom: 4px;
}
#participantsBanner .pb-names {
font-family: 'Syne', sans-serif;
font-size: 18px;
font-weight: 700;
}
#participantsBanner .pb-meta {
margin-left: auto;
font-size: 12.5px;
text-align: right;
opacity: 0.92;
line-height: 1.5;
}
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--amber); transition: width 0.4s ease; }
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
/* CARROUSEL */
.carousel-wrap { margin-bottom: 36px; }
.carousel-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.nav-btn {
width: 38px; height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: #fff;
font-size: 16px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, border-color 0.15s;
}
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
.nav-btn:disabled { opacity: 0.3; cursor: default; }
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
.dots { display: flex; gap: 7px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
.dot.active { background: var(--amber); transform: scale(1.3); }
.theme-panel {
background: #fff;
border: 1px solid var(--border);
border-radius: 18px;
padding: 26px 28px;
min-height: 360px;
}
.theme-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.theme-icon {
width: 44px; height: 44px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; white-space: nowrap; }
.cards { display: flex; flex-direction: column; gap: 10px; }
.qcard {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 18px;
display: flex;
gap: 12px;
align-items: flex-start;
transition: box-shadow 0.2s;
}
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
.qcard .badge {
width: 22px; height: 22px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 12px;
flex-shrink: 0;
margin-top: 1px;
}
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
.qcard .badge.ko { background: var(--coral-light); color: var(--coral); }
.qcard .badge.miss { background: rgba(0,0,0,0.06); color: var(--text-muted); }
.qcard .qtext { flex: 1; min-width: 0; }
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; overflow-wrap: anywhere; }
.qcard .qanswer.empty { color: var(--text-muted); font-style: italic; }
.qcard .qexpected { font-size: 12.5px; color: var(--text-muted); margin-top: 4px; }
.qcard .qexpected strong { color: var(--teal); }
/* ANNEXES (TP3 …) */
.annex {
background: #fff;
border: 1px solid var(--border);
border-radius: 18px;
padding: 24px 28px;
margin-bottom: 24px;
}
.annex h2 {
font-family: 'Syne', sans-serif;
font-size: 15px;
font-weight: 800;
margin-bottom: 14px;
}
.annex .kv { font-size: 13.5px; margin-bottom: 6px; }
.annex .kv span { color: var(--text-muted); }
.table-scroll { overflow-x: auto; margin-top: 14px; }
.annex table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 520px; }
.annex th, .annex td {
border: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
vertical-align: top;
}
.annex th { background: var(--amber-light); font-family: 'Syne', sans-serif; font-size: 12px; }
/* ======= Corrigé de référence — étiquette annotée (repris du TD FDS) ======= */
.recap-intro { font-size: 13.5px; color: var(--text-muted); line-height: 1.6; margin: 0 0 16px; }
.recap-layout { display: flex; gap: 20px; align-items: flex-start; flex-wrap: wrap; }
.recap-stage { flex: 1 1 320px; min-width: 0; max-width: 560px; }
.recap-layout .checklist-table { flex: 1 1 320px; min-width: 0; margin: 0; }
.label-card {
position: relative;
width: 100%; max-width: 640px;
background: #fff; color: #1a1a18;
border: 1px solid #d9d2c4; border-radius: 6px;
padding: 16px 18px 0; overflow: hidden;
font-family: 'DM Sans', sans-serif; font-size: 10.5px; line-height: 1.42;
box-shadow: 0 8px 30px rgba(0,0,0,0.10);
}
.lbl-top { display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #1a1a18; padding-bottom: 6px; margin-bottom: 10px; }
.lbl-flag { font-size: 20px; }
.lbl-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: clamp(24px, 5vw, 40px); letter-spacing: -0.01em; margin: 0; }
.lbl-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.lbl-h { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.02em; margin-bottom: 2px; }
.lbl-block { margin-bottom: 9px; }
.lbl-small { color: #555; font-size: 9.5px; line-height: 1.6; margin-top: 4px; }
.lbl-bottom { display: grid; grid-template-columns: auto 1fr auto; align-items: end; gap: 14px; margin-top: 4px; padding: 10px 0; }
.lbl-net { display: flex; flex-direction: column; gap: 2px; }
.lbl-net span { font-size: 10px; }
.lbl-net strong { font-family: 'Syne', sans-serif; font-size: 22px; line-height: 1; }
.lbl-barcode { height: 42px; align-self: end; min-width: 90px; background: repeating-linear-gradient(90deg, #1a1a18 0 2px, #fff 2px 4px, #1a1a18 4px 5px, #fff 5px 9px); }
.lbl-picto-wrap { display: flex; align-items: center; gap: 8px; }
.pictos { display: flex; gap: 6px; align-items: center; }
.ghs-img { width: 42px; height: 42px; object-fit: contain; display: block; }
.lbl-danger { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 15px; color: #1a1a18; letter-spacing: 0.02em; }
.lbl-footer { margin: 6px -18px 0; padding: 8px 18px; background: #2b2b28; color: #e8e2d5; text-align: center; font-size: 9px; line-height: 1.5; }
.hotspot { position: relative; border-radius: 5px; }
span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
.hotspot.hs-correct { background: rgba(46,125,50,0.14); outline: 1.5px solid #2e7d32; }
.hs-num { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #2e7d32; color: #fff; font-family: 'Syne', sans-serif; font-weight: 800; font-size: 9px; margin-right: 3px; vertical-align: middle; }
.checklist-table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; font-size: 12.5px; }
.checklist-table th { background: var(--teal-light); color: var(--teal); font-family: 'Syne', sans-serif; font-weight: 700; font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.03em; padding: 10px 12px; text-align: left; border-bottom: 2px solid var(--teal); }
.checklist-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); color: var(--text-muted); vertical-align: top; }
.checklist-table tr:last-child td { border-bottom: none; }
.checklist-table td.cl-num { text-align: center; }
.cl-badge { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: #2e7d32; color: #fff; font-family: 'Syne', sans-serif; font-weight: 800; font-size: 10px; }
.checklist-table strong { color: var(--text); }
@media (max-width: 640px) {
.lbl-cols { grid-template-columns: 1fr; }
.lbl-title { font-size: 26px; }
}
</style>
</head>
<body>
<header>
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
<nav>
<a href="../index.html">Accueil</a>
<a href="../index.html#formations">Exercices</a>
<a href="../index.html#documentation">Documentation</a>
<a href="../index.html#rapports">Rapports</a>
<a href="../index.html#about">À propos</a>
<a href="../index.html#contact">Contact</a>
</nav>
</header>
<div class="wrap">
<div id="setup">
<div class="eyebrow">Certibiocide — Correction des rendus</div>
<h1>Corriger un exercice</h1>
<p class="lead">Sélectionnez l'exercice concerné, puis déposez le fichier PDF exporté par le groupe. Les réponses s'affichent directement ici, exercice par exercice.</p>
<p class="step-label"><span class="step-num">1</span> Choisissez l'exercice à corriger</p>
<div class="exo-grid" id="exoGrid">
<div class="exo-option">
<input type="radio" name="exo" id="exo-biocid" value="biocid">
<label for="exo-biocid">
<span class="exo-icon">🧪</span>
<span class="exo-text">
<span class="exo-name">Plateforme BioCID</span>
<span class="exo-sub">Recherche de produits biocides</span>
</span>
<span class="exo-check"></span>
</label>
</div>
<div class="exo-option">
<input type="radio" name="exo" id="exo-fds" value="fds">
<label for="exo-fds">
<span class="exo-icon">📋</span>
<span class="exo-text">
<span class="exo-name">FDS, FT &amp; Étiquettes</span>
<span class="exo-sub">Lecture documentaire</span>
</span>
<span class="exo-check"></span>
</label>
</div>
<div class="exo-option">
<input type="radio" name="exo" id="exo-tp" value="tp">
<label for="exo-tp">
<span class="exo-icon">📚</span>
<span class="exo-text">
<span class="exo-name">TP Nuisibles final</span>
<span class="exo-sub">Étude de cas de synthèse</span>
</span>
<span class="exo-check"></span>
</label>
</div>
</div>
<p class="step-label"><span class="step-num">2</span> Déposez le rendu PDF</p>
<div id="dropzone" class="disabled">
<div class="icon">📄</div>
<p class="title">Déposez le PDF du groupe ici</p>
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis l'exercice</p>
<label class="btn-pick">Choisir un fichier
<input type="file" id="filePicker" accept="application/pdf">
</label>
<div id="status" class="hint">Sélectionnez d'abord un exercice ci-dessus.</div>
</div>
</div>
<div id="result">
<div id="participantsBanner" style="display:none;"></div>
<div class="eyebrow">Certibiocide — Correction d'exercice</div>
<h1 id="resultTitle"></h1>
<div class="toprow">
<span id="fileName"></span>
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
</div>
<div class="progress-row">
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
<span><strong id="progressCount"></strong> <span id="progressLabel"></span></span>
</div>
<div class="carousel-wrap">
<div class="carousel-nav">
<button class="nav-btn" id="prevBtn" onclick="goTo(currentGroup - 1)"></button>
<div class="dots" id="dots"></div>
<button class="nav-btn" id="nextBtn" onclick="goTo(currentGroup + 1)"></button>
</div>
<div id="groupPanel"></div>
</div>
<div id="annexes"></div>
</div>
</div>
<footer>
<p>&copy; 2026 Gauthier Chombart &amp; Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide &amp; Techniques — Formateurs indépendants</p>
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart &amp; Nathan Chauwin — Formateurs indépendants</p>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
/* ======================= OUTILS COMMUNS ======================= */
const COLOR_MAP = {
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
purple: { bg: "var(--purple-light)", fg: "var(--purple)" },
};
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function escapeHtml(s) {
if (!s) return "";
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function isEmpty(v) {
if (!v) return true;
const t = v.trim();
return t === "" || /^\(?sans réponse\)?$/i.test(t) || t === "—";
}
function captureBetween(text, label, nextLabels) {
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
const m = startRx.exec(text);
if (!m) return "";
const from = m.index + m[0].length;
let to = text.length;
for (const nl of nextLabels || []) {
const endRx = new RegExp(escapeRx(nl), "i");
const m2 = endRx.exec(text.slice(from));
if (m2 && from + m2.index < to) to = from + m2.index;
}
return text.slice(from, to).trim();
}
// Aplatit le texte et retire les en-têtes/pieds d'impression du navigateur.
function flatten(raw) {
let clean = raw.replace(/\r/g, "").replace(/\s+/g, " ").trim();
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
clean = clean.replace(/Page\s*\d{1,3}\s*sur\s*\d{1,3}/gi, " ");
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
clean = clean.replace(/about:blank/gi, " ");
clean = clean.replace(/file:\/\/\S+/gi, " ");
return clean.replace(/\s+/g, " ").trim();
}
// Sépare "Groupe 3 — Alice Bob" en { groupe, participants }
function splitGroupe(nom) {
const t = (nom || "").trim();
const m = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(t);
if (m) return { groupe: "Groupe " + m[1], participants: m[2].trim() };
return { groupe: "", participants: t };
}
function readMeta(clean, lastKey) {
const meta = {
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
formateur: captureBetween(clean, "Formateur", [lastKey]),
};
Object.assign(meta, splitGroupe(meta.nom));
return meta;
}
/* ======================= EXERCICE 1 — BIOCID ======================= */
const BIOCID_GROUPS = [
{ title: "Exercice 1 — Recherche par nom", icon: "🔎", color: "amber", items: [
"Ex1 — Produit : type, substance active, fabricant",
"Ex1 — Date d'autorisation",
"Ex1 — Conditions d'utilisation",
]},
{ title: "Exercice 2 — Recherche par type", icon: "🗂️", color: "blue", items: [
"Ex2 — Nombre de produits dans la catégorie",
"Ex2 — Comparaison entre catégories",
]},
{ title: "Exercice 3 — Recherche par substance", icon: "🧪", color: "teal", items: [
"Ex3 — 3 produits contenant la substance",
"Ex3 — Formulations et concentrations comparées",
]},
{ title: "Exercice 4 — Cas de synthèse", icon: "📞", color: "coral", items: [
"Ex4 — 3 produits applicables",
"Ex4 — Fiche de chaque produit",
"Ex4 — Argumentaire (+ bonus FDS)",
]},
];
function parseBiocid(raw) {
let clean = flatten(raw);
clean = clean.replace(/Exercices BioCID\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ").replace(/\s+/g, " ").trim();
const meta = readMeta(clean, "Réponses aux exercices");
const startIdx = clean.search(/Réponses aux exercices/i);
const block = startIdx >= 0 ? clean.slice(startIdx) : clean;
const all = BIOCID_GROUPS.flatMap(g => g.items);
const answers = {};
all.forEach((label, i) => {
answers[label] = captureBetween(block, label, all[i + 1] ? [all[i + 1]] : []);
});
const groups = BIOCID_GROUPS.map(g => ({
title: g.title, icon: g.icon, color: g.color,
items: g.items.map(label => {
const value = answers[label] || "";
return {
label: label.replace(/^Ex\d+\s*[—-]\s*/, ""),
answer: value,
state: isEmpty(value) ? "miss" : "ok",
};
}),
}));
return { title: "Plateforme BioCID — Lecture du rendu", meta, groups, scored: false };
}
/* ======================= EXERCICE 2 — FDS / FT / ÉTIQUETTE ======================= */
const fdsQuestions = [
{ text: "Quel est le nom du produit et qui est le fabricant ?", answer: "1" },
{ text: "Quels sont les dangers du produit ?", answer: "2" },
{ text: "Quelles sont les molécules actives et leurs concentrations ?", answer: "3" },
{ text: "Quels gestes adopter en premiers secours ?", answer: "4" },
{ text: "En cas d'incendie, quels sont les moyens d'extinction conseillés ?", answer: "5" },
{ text: "Que faire en cas de dispersion accidentelle ?", answer: "6" },
{ text: "Dans quelles conditions stocker mon produit ?", answer: "7" },
{ text: "Quels équipements de protection individuelle (EPI) dois-je porter ?", answer: "8" },
{ text: "Quel est le pH de ce produit ?", answer: "9" },
{ text: "Le produit est-il stable dans des conditions normales ?", answer: "10" },
{ text: "Quels sont les effets potentiels sur la santé ?", answer: "11" },
{ text: "Quel est le degré de toxicité pour les poissons ? Potentiel de bioaccumulation ?", answer: "12" },
{ text: "Comment éliminer le produit ?", answer: "13" },
{ text: "Est-ce que mon produit est classé dangereux pour le transport ?", answer: "14" },
{ text: "En cas d'ingestion du produit, que faire ?", answer: "4,11" },
{ text: "Où trouver le mode d'emploi du produit ?", answer: "absent" },
];
const ftQuestions = [
{ text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" },
{ text: "Sur quelles matières ce produit est-il compatible ?", answer: "Description" },
{ text: "Quel est (sont) le(s) type(s) de ce produit ?", answer: "Propriétés biocides" },
{ text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" },
{ text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" },
];
const etiquetteQuestions = [
{ text: "Quel est le nom du produit et le nom de l'entreprise ?", answer: "Identité" },
{ text: "Quels pictogrammes de danger sont affichés ?", answer: "Pictogrammes" },
{ text: "Quels sont les mots d'avertissement (« Attention » ou « Danger ») ?", answer: "Mots d'avertissement" },
{ text: "Quelles sont les mentions de danger (H-statements) identifiées ?", answer: "Mentions de danger" },
{ text: "Quels équipements de protection individuelle (EPI) sont recommandés ?", answer: "Conseils de prudence" },
{ text: "Quels premiers secours sont indiqués ?", answer: "Conseils de prudence" },
{ text: "Comment éliminer l'emballage après utilisation ?", answer: "Conseils de prudence" },
{ text: "Quel est le contenu net du produit ?", answer: "Contenu net" },
{ text: "Y a-t-il un numéro d'appel d'urgence mentionné ?", answer: "Numéro d'urgence" },
{ text: "Quelles consignes de stockage sont affichées ?", answer: "Conseils de prudence" },
];
const FDS_PARTS = [
{ prefix: "P1", title: "Partie 1 — FDS", icon: "📄", color: "amber", questions: fdsQuestions, kind: "fds" },
{ prefix: "P2", title: "Partie 2 — Fiche Technique", icon: "📋", color: "coral", questions: ftQuestions, kind: "text" },
{ prefix: "P3", title: "Partie 3 — Étiquette", icon: "🏷️", color: "teal", questions: etiquetteQuestions, kind: "text" },
];
// Récupère « Réponse du groupe : X — Attendu : … » pour un marqueur [Px-Qn]
function extractSelected(fullText, marker, nextMarker) {
const seg = captureBetween(fullText, marker, nextMarker ? [nextMarker] : []);
const m = /Réponse du groupe\s*:?\s*([\s\S]*?)\s*[—–-]*\s*Attendu\s*:/i.exec(seg);
return m ? m[1].trim() : "";
}
function normFdsToken(sel) {
if (!sel || /non répondu/i.test(sel)) return null;
if (/^absent$/i.test(sel.trim())) return "absent";
const m = /(\d+)/.exec(sel);
return m ? m[1] : "";
}
function scoreFds(q, sel) {
const tok = normFdsToken(sel);
if (tok === null) return "miss";
return q.answer.split(",").includes(tok) ? "ok" : "ko";
}
function scoreText(q, sel) {
if (!sel || /non répondu/i.test(sel)) return "miss";
const norm = /^absent$/i.test(sel.trim()) ? "absent" : sel.trim();
return norm.toLowerCase() === q.answer.toLowerCase() ? "ok" : "ko";
}
function fdsExpectedLabel(part, q) {
if (part.kind !== "fds") return q.answer;
const parts = q.answer.split(",");
if (parts.length > 1) return "Rubrique " + parts.join(" ou ");
return parts[0] === "absent" ? "Absent de la FDS" : "Rubrique " + parts[0];
}
/* ---- Corrigé de référence : l'étiquette annotée + check-list (repris du TD) ---- */
const ETI_ZONES = [
{ zone: "z-nom", label: "Nom commercial",
reg: "Obligation CLP : le nom commercial doit être identique à celui figurant sur la FDS (Guide FHER, Partie 3 — I.2)." },
{ zone: "z-picto", label: "Pictogrammes de danger",
reg: "CLP : pictogrammes aux dimensions requises, regroupés avec la mention d'avertissement et les phrases H/P, se détachant nettement du fond (Partie 3 — I.5)." },
{ zone: "z-mention", label: "Mention d'avertissement",
reg: "CLP : « Danger » ou « Attention », disposée dans l'encart d'étiquetage avec pictogrammes et phrases H/P (Partie 3 — I.5)." },
{ zone: "z-h", label: "Mentions de danger (phrases H)",
reg: "CLP : les phrases H décrivent la nature du danger ; regroupées dans l'encart d'étiquetage (Partie 3 — I.5)." },
{ zone: "z-p-epi", label: "Conseils de prudence — EPI",
reg: "CLP : conseils de prudence P (ici P280). Six conseils P au maximum, sauf si la nature et la gravité des dangers l'exigent (CLP Art. 28-3) — Partie 3, I.5." },
{ zone: "z-p-secours", label: "Premiers secours (phrases P)",
reg: "BPR : indications relatives aux premiers soins (Partie 3 — II.8), traduites par les conseils P 3xx (contact, ingestion, inhalation)." },
{ zone: "z-p-elim", label: "Élimination du produit et de l'emballage",
reg: "BPR : instructions pour l'élimination en toute sécurité du produit et de son emballage, le cas échéant avec interdiction de le réutiliser (Partie 3 — II.10). Ici P501." },
{ zone: "z-net", label: "Contenu net",
reg: "Quantité nette de produit contenue dans l'emballage — mention générale du pavé d'information produit." },
{ zone: "z-antipoison", label: "Centre antipoison / n° d'urgence",
reg: "Coordonnées d'urgence complétant les instructions de premiers soins (Partie 3 — II.8) ; les mélanges dangereux sont déclarés aux centres antipoison (annexe VIII du CLP)." },
];
const ETI_LABEL_CARD = `
<div class="label-card recap">
<div class="lbl-top">
<span class="lbl-flag">🇫🇷</span>
<span class="hotspot" data-zone="z-nom"><span class="lbl-title">DÉS'INFECT</span></span>
</div>
<div class="lbl-cols">
<div class="lbl-col">
<div class="lbl-block"><div class="lbl-h">Propriétés :</div>Désinfecte et désodorise les sanitaires. Produit strictement destiné à un usage professionnel.</div>
<div class="lbl-block"><div class="lbl-h">Mode d'emploi :</div>Concentré à diluer : 5 % pour une activité fongicide, 1 % pour une activité bactéricide. Pulvériser sur les parois, laisser agir 5 à 15 min, rincer à l'eau claire. Type de formulation : concentré soluble (SL). Utilisable en TP2.</div>
<div class="lbl-small">UFI : 1234-1234-1234-1234<br>Date de péremption : MM/AAAA<br>N° LOT : 123456</div>
</div>
<div class="lbl-col">
<div class="lbl-block"><div class="lbl-h">Substances actives :</div>Chlorure de didécyldiméthylammonium (N°CAS 7173-51-5) à 75 g/kg ; acide glycolique (CAS 79-14-1) à 35 g/kg.</div>
<div class="lbl-block">
<div class="lbl-h">Précautions d'emploi :</div>
<span>Produit strictement professionnel (FR) — Lire le mode d'emploi ci-joint avant l'emploi. </span>
<span class="hotspot" data-zone="z-antipoison">Formule déposée au centre anti-poison de Nancy : 03 83 22 50 50.</span>
<span> </span>
<span class="hotspot" data-zone="z-h">H302 Nocif en cas d'ingestion. H314 Provoque de graves brûlures de la peau et des lésions oculaires. H410 Très toxique pour les organismes aquatiques, entraîne des effets néfastes à long terme.</span>
<span> </span>
<span class="hotspot" data-zone="z-p-epi">P280 Porter des gants de protection, des vêtements de protection et un équipement de protection du visage.</span>
<span> </span>
<span class="hotspot" data-zone="z-p-secours">P301+P330+P331 EN CAS D'INGESTION : rincer la bouche, ne pas faire vomir. P305+P351+P338 EN CAS DE CONTACT AVEC LES YEUX : rincer avec précaution à l'eau pendant plusieurs minutes. P310 Appeler immédiatement un CENTRE ANTIPOISON ou un médecin.</span>
<span> </span>
<span class="hotspot" data-zone="z-p-elim">P501 Éliminer le contenu / le récipient dans un point de collecte des déchets spéciaux ou dangereux.</span>
</div>
</div>
</div>
<div class="lbl-bottom">
<span class="hotspot" data-zone="z-net">
<span class="lbl-net"><span>Volume net :</span><strong>5 L</strong></span>
</span>
<div class="lbl-barcode" aria-hidden="true"></div>
<div class="lbl-picto-wrap">
<span class="hotspot" data-zone="z-picto">
<span class="pictos">
<img class="ghs-img" src="../data/ghs09-environnement.jpg" alt="Pictogramme SGH09 — danger pour le milieu aquatique">
<img class="ghs-img" src="../data/ghs05-corrosif.jpg" alt="Pictogramme SGH05 — corrosif">
<img class="ghs-img" src="../data/ghs07-nocif.jpg" alt="Pictogramme SGH07 — nocif / irritant">
</span>
</span>
<span class="hotspot" data-zone="z-mention"><span class="lbl-danger">DANGER</span></span>
</div>
</div>
<div class="lbl-footer">FHER — 2, rue de Sèze, 75009 Paris — Tél. 01 23 45 67 89</div>
</div>`;
// Construit le corrigé de référence : étiquette annotée (zones numérotées) + check-list réglementaire.
function buildEtiquetteRecapHtml() {
const wrap = document.createElement("div");
wrap.innerHTML = ETI_LABEL_CARD;
const card = wrap.querySelector(".label-card");
card.querySelectorAll(".hotspot").forEach(h => {
h.classList.add("hs-correct");
const idx = ETI_ZONES.findIndex(z => z.zone === h.dataset.zone);
if (idx >= 0) {
const num = document.createElement("b");
num.className = "hs-num";
num.textContent = idx + 1;
h.insertBefore(num, h.firstChild);
}
});
let rows = '<thead><tr><th style="width:34px;">N°</th><th>Élément d\'étiquetage</th><th>Base réglementaire</th></tr></thead><tbody>';
ETI_ZONES.forEach((z, i) => {
rows += `<tr>
<td class="cl-num"><span class="cl-badge">${i + 1}</span></td>
<td><strong>${escapeHtml(z.label)}</strong></td>
<td>${escapeHtml(z.reg)}</td>
</tr>`;
});
rows += "</tbody>";
return `<p class="recap-intro">Les 9 zones réglementaires et leurs bonnes réponses, présentées comme la <strong>check-list officielle d'étiquetage des produits biocides</strong> (Guide FHER, p. 15) — un outil réutilisable en conditions réelles.</p>
<div class="recap-layout">
<div class="recap-stage">${card.outerHTML}</div>
<table class="checklist-table">${rows}</table>
</div>`;
}
function parseFds(raw) {
let clean = flatten(raw);
clean = clean.replace(/FDS, FT (&|&amp;) Étiquettes\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ").replace(/\s+/g, " ").trim();
const meta = readMeta(clean, "Score global");
const markers = [];
FDS_PARTS.forEach(p => p.questions.forEach((q, i) => markers.push("[" + p.prefix + "-Q" + (i + 1) + "]")));
let mIdx = 0;
const groups = FDS_PARTS.map(part => ({
title: part.title, icon: part.icon, color: part.color,
items: part.questions.map((q, i) => {
const marker = "[" + part.prefix + "-Q" + (i + 1) + "]";
const sel = extractSelected(clean, marker, markers[mIdx + 1]);
mIdx++;
const state = part.kind === "fds" ? scoreFds(q, sel) : scoreText(q, sel);
return {
label: q.text,
answer: sel,
state,
expected: fdsExpectedLabel(part, q),
};
}),
}));
const annexes = [{
title: "Récapitulatif — l'étiquette annotée (corrigé de référence)",
html: buildEtiquetteRecapHtml(),
}];
return { title: "FDS, FT & Étiquettes — Correction du rendu", meta, groups, scored: true, annexes };
}
/* ======================= EXERCICE 3 — TP NUISIBLES ======================= */
const TP_GROUPS = [
{ title: "Premier contact & diagnostic", icon: "🗣️", color: "purple", items: [
"Questions posées au client lors du premier contact",
"Conseils donnés en premier lieu",
"Actions de prévention menées sur le site",
"Éléments observés / preuve de présence du nuisible",
]},
{ title: "Solutions envisagées", icon: "🧰", color: "blue", items: [
"Solutions alternatives non-biocides",
"Solutions biocides mises en place",
"Type de produit (TP) et mode de traitement",
"Suivi mis en place et durée",
"Gestion des déchets",
]},
{ title: "Produit & traçabilité", icon: "🧪", color: "teal", items: [
"FDS trouvée (éléments principaux)",
"Nom du produit et fabricant/fournisseur",
"Numéro AMM",
]},
{ title: "Sécurité & santé", icon: "🛡️", color: "coral", items: [
"Risques pour la santé",
"EPI à porter",
"Risques environnement / dispersion accidentelle",
"DL50 substance(s) active(s)",
"Gestes de premiers secours",
]},
];
const TP_KNOWN_STEPS = [
"remplir la poudreuse",
"sécurisation du site",
"application traitements",
"rangement du matériel. purger la perche",
"retrait du nid",
"gestion des déchets. élimination par des filiaires spé.",
];
function parseTpRiskTable(tp3Zone) {
const rows = [];
const tableMatch = /Étapes\s*Risques\s*Mesures de prévention([\s\S]*?)(Réalisée par|$)/i.exec(tp3Zone);
if (!tableMatch) return rows;
const raw = tableMatch[1].trim();
if (!raw) return rows;
const positions = [];
TP_KNOWN_STEPS.forEach(step => {
const idx = raw.toLowerCase().indexOf(step.toLowerCase());
if (idx >= 0) positions.push({ step, idx });
});
positions.sort((a, b) => a.idx - b.idx);
if (positions.length === 0) {
rows.push({ etape: raw, risque: "—", mesure: "—" });
return rows;
}
for (let i = 0; i < positions.length; i++) {
const from = positions[i].idx + positions[i].step.length;
const to = i + 1 < positions.length ? positions[i + 1].idx : raw.length;
const rest = raw.slice(from, to).trim();
const measureRx = /(gants\s*\/\s*masques\s*\/\s*lunettes[^.]*|barrieres\.?\s*epi)/i;
const mm = measureRx.exec(rest);
let risque = rest, mesure = "";
if (mm) {
risque = rest.slice(0, mm.index).trim();
mesure = rest.slice(mm.index).trim();
}
risque = risque.replace(/^[\s\-.]+/, "").trim();
rows.push({ etape: positions[i].step, risque: risque || "—", mesure: mesure || "—" });
}
return rows;
}
function parseTp(raw) {
let clean = flatten(raw);
clean = clean.replace(/Résultats TP\s*[—–-]\s*[^[]{0,80}?(?=Groupe \/ Stagiaire)/gi, " ").replace(/\s+/g, " ").trim();
const meta = readMeta(clean, "Cas pratique");
meta.cas = captureBetween(clean, "Cas pratique", ["Réponses TP1"]);
const startIdx = clean.search(/Réponses TP1\s*&?\s*TP2/i);
const endIdx = clean.search(/TP3\s*[—–-]\s*Analyse de risques/i);
const block = startIdx >= 0 ? clean.slice(startIdx, endIdx >= 0 ? endIdx : clean.length) : clean;
const all = TP_GROUPS.flatMap(g => g.items);
const answers = {};
all.forEach((label, i) => {
answers[label] = captureBetween(block, label, all[i + 1] ? [all[i + 1]] : []);
});
const groups = TP_GROUPS.map(g => ({
title: g.title, icon: g.icon, color: g.color,
items: g.items.map(label => ({
label,
answer: answers[label] || "",
state: isEmpty(answers[label]) ? "miss" : "ok",
})),
}));
const tp3Zone = endIdx >= 0 ? clean.slice(endIdx) : "";
const tp3 = {
intitule: captureBetween(tp3Zone, "Intitulé", ["Description"]),
description: captureBetween(tp3Zone, "Description", ["Lieu"]),
lieu: captureBetween(tp3Zone, "Lieu", ["Début prévu le"]),
debut: captureBetween(tp3Zone, "Début prévu le", ["Étapes"]),
};
const riskRows = parseTpRiskTable(tp3Zone);
const mReal = clean.match(/Réalisée par\s*:\s*([^—\n]*)/i);
const mVerif = clean.match(/Vérifiée par\s*:\s*([^—\n]*)/i);
const mMaj = clean.match(/Mise à jour par\s*:\s*([^—\n]*)/i);
const annexes = [];
if (tp3.intitule || riskRows.length) {
annexes.push({
title: "TP3 — Analyse de risques",
html: `
<div class="kv"><span>Intitulé :</span> ${escapeHtml(tp3.intitule) || "—"}</div>
<div class="kv"><span>Description :</span> ${escapeHtml(tp3.description) || "—"}</div>
<div class="kv"><span>Lieu :</span> ${escapeHtml(tp3.lieu) || "—"}</div>
<div class="kv"><span>Début prévu le :</span> ${escapeHtml(tp3.debut) || "—"}</div>
${riskRows.length ? `
<div class="table-scroll">
<table>
<tr><th>Étapes</th><th>Risques</th><th>Mesures de prévention</th></tr>
${riskRows.map(r => `<tr><td>${escapeHtml(r.etape)}</td><td>${escapeHtml(r.risque)}</td><td>${escapeHtml(r.mesure)}</td></tr>`).join("")}
</table>
</div>` : ""}
<div class="kv" style="margin-top:14px;">
<span>Réalisée par :</span> ${escapeHtml(mReal ? mReal[1].trim() : "") || "—"} &nbsp;·&nbsp;
<span>Vérifiée par :</span> ${escapeHtml(mVerif ? mVerif[1].trim() : "") || "—"} &nbsp;·&nbsp;
<span>Mise à jour par :</span> ${escapeHtml(mMaj ? mMaj[1].trim() : "") || "—"}
</div>
`,
});
}
return { title: "TP Nuisibles — Lecture du rendu", meta, groups, scored: false, annexes };
}
/* ======================= REGISTRE DES EXERCICES ======================= */
const EXERCISES = {
biocid: {
parse: parseBiocid,
// Signature : en-tête propre au PDF exporté par Exo_BioCID.html
signature: /Exercices\s+Pratiques\s+BioCID/i,
sentinel: /Réponses aux exercices|Ex1\s*[—–-]/i,
name: "Plate-forme BioCID",
},
fds: {
parse: parseFds,
signature: /TD\s*[—–-]\s*FDS,\s*FT/i,
sentinel: /\[P1-Q1\]|Partie 1\s*[—–-]\s*FDS/i,
name: "FDS, FT & Étiquettes",
},
tp: {
parse: parseTp,
signature: /TP\s+Nuisibles\s*[—–-]\s*Certibiocide/i,
sentinel: /Réponses TP1|TP3\s*[—–-]\s*Analyse de risques/i,
name: "TP Nuisibles",
},
};
// Identifie l'exercice réellement contenu dans le PDF (signature d'abord, sentinelle en secours).
function detectExercise(text) {
for (const [key, cfg] of Object.entries(EXERCISES)) {
if (cfg.signature.test(text)) return key;
}
for (const [key, cfg] of Object.entries(EXERCISES)) {
if (cfg.sentinel.test(text)) return key;
}
return null;
}
/* ======================= RENDU ======================= */
let currentData = null;
let currentGroup = 0;
function renderGroupPanel() {
const group = currentData.groups[currentGroup];
const colors = COLOR_MAP[group.color] || COLOR_MAP.amber;
const good = group.items.filter(it => it.state === "ok").length;
const countLabel = currentData.scored
? `${good}/${group.items.length} corrects`
: `${good}/${group.items.length} renseignées`;
document.getElementById("groupPanel").innerHTML = `
<div class="theme-panel">
<div class="theme-head">
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${group.icon}</div>
<div class="theme-title">${escapeHtml(group.title)}</div>
<div class="theme-count">${countLabel}</div>
</div>
<div class="cards">
${group.items.map(it => {
const mark = it.state === "ok" ? "✓" : (it.state === "ko" ? "✗" : "!");
const empty = it.state === "miss";
return `
<div class="qcard">
<div class="badge ${it.state}">${mark}</div>
<div class="qtext">
<div class="qlabel">${escapeHtml(it.label)}</div>
<div class="qanswer ${empty ? "empty" : ""}">${empty ? "Sans réponse" : escapeHtml(it.answer)}</div>
${it.expected && it.state !== "ok"
? `<div class="qexpected">Attendu : <strong>${escapeHtml(it.expected)}</strong></div>`
: ""}
</div>
</div>`;
}).join("")}
</div>
</div>`;
document.getElementById("prevBtn").disabled = currentGroup === 0;
document.getElementById("nextBtn").disabled = currentGroup === currentData.groups.length - 1;
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentGroup));
}
function goTo(idx) {
if (!currentData || idx < 0 || idx >= currentData.groups.length) return;
currentGroup = idx;
renderGroupPanel();
}
function renderResult(data) {
currentData = data;
currentGroup = 0;
document.getElementById("resultTitle").textContent = data.title;
const banner = document.getElementById("participantsBanner");
const noms = data.meta.participants || data.meta.nom;
if (noms) {
banner.style.display = "flex";
banner.innerHTML = `
<div class="pb-icon">👥</div>
<div>
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
<div class="pb-names">${escapeHtml(noms)}</div>
</div>
<div class="pb-meta">
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) + "<br>" : ""}
${data.meta.cas ? "Cas : " + escapeHtml(data.meta.cas) : ""}
</div>`;
} else {
banner.style.display = "none";
}
const allItems = data.groups.flatMap(g => g.items);
const good = allItems.filter(it => it.state === "ok").length;
document.getElementById("progressFill").style.width = (good / allItems.length * 100) + "%";
document.getElementById("progressCount").textContent = `${good} / ${allItems.length}`;
document.getElementById("progressLabel").textContent = data.scored ? "réponses correctes" : "questions renseignées";
document.getElementById("dots").innerHTML = data.groups.map((_, i) =>
`<div class="dot${i === 0 ? " active" : ""}" onclick="goTo(${i})"></div>`
).join("");
document.getElementById("annexes").innerHTML = (data.annexes || []).map(a =>
`<div class="annex"><h2>${escapeHtml(a.title)}</h2>${a.html}</div>`
).join("");
renderGroupPanel();
document.getElementById("setup").classList.add("hidden");
document.getElementById("result").style.display = "block";
window.scrollTo({ top: 0, behavior: "smooth" });
}
/* ======================= CHARGEMENT DU PDF ======================= */
const dropzone = document.getElementById("dropzone");
const statusEl = document.getElementById("status");
const filePicker = document.getElementById("filePicker");
function selectedExo() {
const el = document.querySelector('input[name="exo"]:checked');
return el ? el.value : null;
}
function refreshDropzone() {
if (selectedExo()) {
dropzone.classList.remove("disabled");
statusEl.className = "hint";
statusEl.textContent = "Prêt à recevoir le PDF.";
} else {
dropzone.classList.add("disabled");
statusEl.className = "hint";
statusEl.textContent = "Sélectionnez d'abord un exercice ci-dessus.";
}
}
async function readPdfText(file) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
let fullText = "";
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const content = await page.getTextContent();
fullText += content.items.map(it => it.str).join(" ") + "\n";
}
return fullText;
}
async function handleFile(file) {
const exo = selectedExo();
if (!exo) {
statusEl.className = "error";
statusEl.textContent = "Choisissez un exercice avant de déposer un fichier.";
return;
}
if (!file) return;
if (file.type !== "application/pdf" && !/\.pdf$/i.test(file.name)) {
statusEl.className = "error";
statusEl.textContent = "Le fichier doit être un PDF.";
return;
}
statusEl.className = "loading";
statusEl.textContent = "Lecture du PDF en cours…";
try {
const text = await readPdfText(file);
const config = EXERCISES[exo];
const detected = detectExercise(text);
if (detected && detected !== exo) {
statusEl.className = "error";
statusEl.textContent = `Fichier incompatible : ce PDF est un rendu « ${EXERCISES[detected].name} », or vous avez sélectionné « ${config.name} ». Changez d'exercice ou déposez le bon fichier.`;
return;
}
if (!detected) {
statusEl.className = "error";
statusEl.textContent = `Ce PDF ne ressemble pas à un rendu « ${config.name} ». Vérifiez l'exercice sélectionné.`;
return;
}
const data = config.parse(text);
document.getElementById("fileName").textContent = file.name;
renderResult(data);
statusEl.className = "hint";
statusEl.textContent = "";
} catch (e) {
console.error(e);
statusEl.className = "error";
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export d'exercice.";
}
}
function resetAll() {
currentData = null;
document.getElementById("result").style.display = "none";
document.getElementById("setup").classList.remove("hidden");
document.getElementById("participantsBanner").style.display = "none";
document.getElementById("annexes").innerHTML = "";
filePicker.value = "";
refreshDropzone();
window.scrollTo({ top: 0, behavior: "smooth" });
}
// Préselection via ?exo=biocid|fds|tp
(function preselect() {
const exo = new URLSearchParams(location.search).get("exo");
if (exo && EXERCISES[exo]) {
const input = document.getElementById("exo-" + exo);
if (input) input.checked = true;
}
})();
document.querySelectorAll('input[name="exo"]').forEach(r =>
r.addEventListener("change", refreshDropzone)
);
refreshDropzone();
dropzone.addEventListener("dragover", e => {
if (dropzone.classList.contains("disabled")) return;
e.preventDefault();
dropzone.classList.add("drag");
});
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
dropzone.addEventListener("drop", e => {
if (dropzone.classList.contains("disabled")) return;
e.preventDefault();
dropzone.classList.remove("drag");
handleFile(e.dataTransfer.files && e.dataTransfer.files[0]);
});
filePicker.addEventListener("change", e => handleFile(e.target.files && e.target.files[0]));
</script>
</body>
</html>