Convertit un PDF de cours en une page HTML autonome, prête à déposer comme ressource « Fichier » sur Moodle.
Le fichier produit ne dépend d'aucune ressource externe : styles, script et images sont intégrés au fichier, qui s'ouvre donc hors ligne. Il embarque un sommaire latéral, un zoom, un mode plein écran et un suivi de lecture.
Rien n'est conservé sur le serveur. Le PDF reçu et le HTML produit ne sont jamais écrits sur disque : tout le traitement se fait en mémoire et le résultat est renvoyé directement dans la réponse HTTP.
flowchart LR
A[PDF de cours] --> B[Formulaire web]
B --> C{{POST /api/convert}}
C --> D[PyMuPDF<br/>page → JPEG]
C --> E[Extraction<br/>des titres]
D --> F[Document HTML<br/>autonome]
E --> F
F --> G[Téléchargement]
G --> H[Ressource Fichier<br/>sur Moodle]
- Ouvrez l'outil et déposez le PDF (glisser-déposer ou clic).
- Renseignez ce que vous voulez voir en en-tête — badge, titre, sous-titre, auteur. Tout est facultatif ; à défaut, le titre reprend le nom du fichier.
- Choisissez la qualité :
- Basse pour un support déjà très lisible, ou si Moodle limite la taille ;
- Moyenne dans la plupart des cas ;
- Haute quand les pages contiennent du texte fin, des formules ou des schémas denses. Comptez environ trois fois le poids de la qualité basse.
- L'aperçu s'affiche dans la page ; téléchargez le HTML.
- Déposez-le sur Moodle en ressource Fichier (et non « Page » : le contenu est un fichier complet, pas du HTML à coller dans l'éditeur).
Deux points à surveiller côté Moodle :
- La taille maximale d'envoi. Un cours de 30 pages en qualité haute pèse environ 3 Mo, ce qui passe partout ; un support long et dense peut approcher la limite fixée par votre établissement. Si le dépôt échoue, reconvertissez en qualité moyenne ou basse.
- Le mode d'affichage de la ressource. Dans les réglages d'apparence du fichier, choisissez un mode qui ouvre le document plutôt que d'en forcer le téléchargement, sinon vos étudiants récupèrent un fichier au lieu de le lire en ligne. L'intitulé exact du réglage varie selon la version de Moodle.
Le fichier produit est autonome : une fois téléchargé, il s'ouvre sans connexion et reste lisible même si l'outil n'est plus en ligne.
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/uvicorn app.main:app --reloadL'interface est sur http://127.0.0.1:8000.
Lancer les tests :
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/playwright install chromium # pour les tests navigateur
.venv/bin/pytest -qtests/test_browser.py ouvre le document produit dans Chromium — en file://,
donc exactement comme un fichier téléchargé depuis Moodle — et vérifie le zoom,
le sommaire, le suivi de lecture et le panneau mobile. Ces tests sont ignorés
automatiquement si Playwright n'est pas installé.
Tout se règle par variables d'environnement ; les valeurs par défaut conviennent à un usage courant.
| Variable | Défaut | Rôle |
|---|---|---|
TOOL_PASSWORD |
(vide) | Si défini, protège l'outil par authentification HTTP Basic. Vide = accès libre. |
TOOL_USERNAME |
moodle |
Nom d'utilisateur associé. |
MAX_UPLOAD_MB |
50 |
Taille maximale du PDF accepté. |
MAX_PAGES |
150 |
Nombre maximal de pages. |
RENDER_TIMEOUT_SECONDS |
120 |
Budget de rendu ; au-delà, réponse 504. |
Sur un sous-domaine public, définissez
TOOL_PASSWORD. Sans lui, l'outil est ouvert à tous.
multipart/form-data :
| Champ | Obligatoire | Description |
|---|---|---|
file |
oui | Le PDF (50 Mo / 150 pages maximum). |
badge |
non | Petite étiquette au-dessus du titre. |
title |
non | Titre du document. À défaut, déduit du nom du fichier. |
subtitle |
non | Sous-titre. |
author |
non | Ligne d'auteur. |
quality |
non | low | medium | high (défaut medium). |
Réponse : le HTML en pièce jointe
(Content-Disposition: attachment, Content-Type: text/html), avec un
en-tête X-Page-Count.
Les paliers de qualité correspondent aux facteurs d'échelle PyMuPDF
1.2 / 1.6 / 2.2 et aux qualités JPEG 72 / 82 / 88.
En cas d'échec, la réponse est un JSON {"error": "…"} avec un code adapté :
| Code | Cas |
|---|---|
400 |
Fichier absent, non-PDF, vide, corrompu ou protégé par mot de passe. |
413 |
Fichier trop volumineux ou trop de pages. |
422 |
Champ file manquant dans la requête. |
504 |
Budget de rendu dépassé. |
Sonde de disponibilité, volontairement accessible sans authentification.
-
Sous-domaine. Dans cPanel → Domaines, créez
pdf2moodle.sakai.snpointant vers un répertoire dédié, par exemple~/apps/pdf2moodle. -
Application Python. cPanel → Setup Python App → Create Application :
- Version de Python : 3.12 (ou la plus récente proposée)
- Application root :
pdf2moodle - Application URL : le sous-domaine créé
- Application startup file :
wsgi_entry.py - Application Entry point :
application
Ne déclarez pas
passenger_wsgi.pycomme fichier de démarrage. cPanel génère ce fichier lui-même, avec pour seul rôle de charger le fichier de démarrage déclaré. Le désigner revient à le faire se charger lui-même :RecursionErroret erreur 500 au démarrage. D'où le nomwsgi_entry.py.Notez la commande
source .../bin/activateaffichée par cPanel : elle contient le chemin de l'environnement virtuel créé pour vous. -
Code et dépendances. Déposez le dépôt dans
~/pdf2moodle(Git ou envoi de fichiers), puis en SSH :source ~/virtualenv/pdf2moodle/3.12/bin/activate cd ~/pdf2moodle pip install -r requirements.txt
pymupdfs'installe depuis une roue précompilée : ni compilateur, nipoppler-utils, ni autre binaire système n'est requis. -
Variables d'environnement. Toujours dans Setup Python App, section Environment variables :
TOOL_USERNAME = <votre identifiant> TOOL_PASSWORD = <un mot de passe solide>En ligne de commande, l'équivalent est :
cloudlinux-selector set --interpreter python --app-root pdf2moodle \ --env-vars '{"TOOL_USERNAME":"…","TOOL_PASSWORD":"…"}' cloudlinux-selector restart --interpreter python --app-root pdf2moodle
-
Protection du
.htaccess. Étape obligatoire, pas seulement pour la limite d'envoi :cat deploy/o2switch.htaccess >> ~/pdf2moodle/.htaccess
Passenger impose que la racine du site soit la racine du code. Sans ces règles, le serveur web sert les fichiers du dépôt en statique, sans passer par l'authentification de l'application :
app/config.py,wsgi_entry.py,requirements.txtet les PDF detests/deviennent lisibles par n'importe qui. Le fichier ajoute aussiLimitRequestBody, la limite applicative de FastAPI ne dispensant pas de celle du serveur.Ajoutez-le à la suite du
.htaccessexistant : cPanel y maintient ses propres blocsCLOUDLINUX …qu'il réécrit à chaque modification de l'app.Vérifiez ensuite :
curl -o /dev/null -w '%{http_code}\n' https://<domaine>/app/config.py # 404 curl -o /dev/null -w '%{http_code}\n' https://<domaine>/healthz # 200
-
ModSecurity. Sur O2Switch, ModSecurity bloque tout envoi de fichier — quels que soient le contenu, l'extension et la taille. Le symptôme est trompeur : la requête est rejetée en
406, le serveur fait une redirection interne vers/406.shtml, celle-ci est routée vers l'application qui répond légitimement404. On croit donc à une route manquante alors que la requête n'a jamais atteint le point de conversion.L'outil ne peut pas fonctionner sans envoi de fichier. Deux issues :
# a) désactiver ModSecurity sur ce seul sous-domaine uapi ModSecurity disable_domains domains=<sous-domaine> # pour revenir en arrière : uapi ModSecurity enable_domains domains=<sous-domaine> # b) demander au support de ne lever que la règle en cause # (le log d'audit n'est pas lisible côté utilisateur, l'ID est introuvable seul)
Vérifiez le périmètre après coup — la commande ne doit toucher qu'un domaine :
uapi --output=json ModSecurity list_domains
-
Redémarrage. Bouton Restart de Setup Python App, puis vérifiez :
curl https://pdf2moodle.sakai.sn/healthz
Pourquoi a2wsgi plutôt qu'uvicorn en sous-processus ? Passenger pilote
des applications WSGI ; a2wsgi.ASGIMiddleware fait tourner l'application ASGI
dans une boucle d'événements du processus Passenger lui-même. Aucun port à
réserver, aucun second processus à surveiller, et Passenger garde la maîtrise
des arrêts et redémarrages. Le lancement d'uvicorn en sous-processus fonctionne
aussi mais laisse des processus orphelins lors des rechargements.
-
Projet. New Project → Deploy from GitHub repo → ce dépôt. Le
Dockerfileetrailway.jsonfournis sont détectés automatiquement ;railway.jsondéclare la sonde/healthz. -
Variables. Onglet Variables :
TOOL_PASSWORD = <un mot de passe solide> TOOL_USERNAME = <votre identifiant>PORTest injecté par Railway, ne le définissez pas. -
Domaine. Settings → Networking → Custom Domain :
pdf2moodle.sakai.sn, puis créez chez O2Switch l'enregistrement CNAME indiqué par Railway. Le certificat TLS est émis automatiquement. -
Limite d'envoi. Le routeur Railway accepte les corps volumineux ; la limite de 50 Mo est appliquée par l'application (
MAX_UPLOAD_MB).
pdf2moodle/
├── app/
│ ├── config.py limites, paliers de qualité, authentification
│ ├── converter.py rendu PyMuPDF et extraction des titres
│ ├── html_builder.py assemblage du document autonome
│ ├── main.py application FastAPI et routes
│ ├── templates/
│ │ ├── index.html page d'envoi
│ │ └── document.html.j2 gabarit du document produit
│ └── static/ styles et script de la page d'envoi
├── tests/
│ ├── fixtures/ PDF d'exemple (générés, versionnés)
│ ├── make_fixtures.py génération des PDF d'exemple
│ ├── test_acceptance.py critères d'acceptation
│ ├── test_browser.py comportement réel dans Chromium
│ ├── test_wsgi_entry.py point d'entrée Passenger (chemin a2wsgi)
│ └── test_auth.py protection par mot de passe
├── deploy/
│ └── o2switch.htaccess règles à ajouter au .htaccess (obligatoire)
├── wsgi_entry.py point d'entrée Passenger (O2Switch)
├── Dockerfile / railway.json / Procfile
├── CHANGELOG.md historique, dont les pièges de déploiement
└── requirements.txt
Sur O2Switch, les fichiers de
app/static/sont servis par l'application et non par le serveur web (Passenger prend la racine du site). Chaque affichage de la page d'envoi fait donc trois requêtes à l'application. C'est sans conséquence à l'usage, mais un test automatisé qui martèle le site déclenchera le limiteur de débit d'O2Switch (429).
Le titre de chaque page provient de page.get_text("dict") : les blocs de
texte sont triés de haut en bas, puis de gauche à droite, et le premier bloc
substantiel est retenu.
Sont écartés :
- les blocs de moins de 3 caractères ;
- les dates (
12/03/2025,2025-03-12,14 mars 2025) — sur un support de cours, la date de séance est souvent placée au-dessus du vrai titre ; - les blocs composés uniquement de chiffres et de ponctuation (numéros de page,
3/40, puces isolées).
Si aucun bloc ne convient — page sans couche texte, page vide — le titre
retombe sur Page N. Dans le sommaire, les titres sont tronqués à
60 caractères ; le libellé au-dessus de chaque image reste complet.
- Sommaire fixe à gauche (280 px), numéroté en CSS, avec suivi de la
lecture par
IntersectionObserver. En dessous de 900 px de large il devient un panneau coulissant ouvert par le bouton ☰. - Zoom de 60 % à 160 % par paliers de 10 %. La largeur du conteneur est
compensée (
width: 100/niveau %) pour qu'aucun débordement horizontal n'apparaisse. Un clic sur l'indicateur revient à 100 %. - Plein écran via
requestFullscreen(), avec repliwebkitpour Safari. - Impression : barre d'outils et sommaire masqués, pages non coupées.
Chaque page porte uniquement son titre extrait : aucun compteur, aucune
numérotation, et aucun vocabulaire de présentation — ni dans le texte visible,
ni dans les attributs alt, ni dans le code. C'est vérifié automatiquement par
test_no_banned_vocabulary_anywhere.
- Le PDF reçu reste en mémoire vive du début à la fin.
Starlette confie normalement les fichiers reçus à un
SpooledTemporaryFiledont le seuil de débordement est de 1 Mo — au-delà, le contenu part sur le disque.app/main.pyrelève ce seuil au-dessus de la taille maximale acceptée pour que cela n'arrive jamais. Le testtest_upload_never_rolls_over_to_diskinstrumenterollover()et vérifie qu'elle n'est jamais appelée avec un PDF de plus de 1 Mo. - Les journaux ne contiennent que des métriques anonymes : nombre de pages, palier de qualité, durée, taille de sortie. Jamais de nom de fichier, jamais de contenu.
- La réponse porte
Cache-Control: no-store.
Le Content-Type annoncé par le navigateur n'est qu'un premier filtre : il est
déclaratif et falsifiable. La validation qui fait foi porte sur la signature du
fichier (%PDF-), puis sur son ouverture effective par PyMuPDF. Les PDF
chiffrés sont refusés avec un message explicite.
Les champs du formulaire sont échappés à l'insertion dans le document, et le
nom du fichier de sortie est translittéré en ASCII puis filtré — ce qui écarte
aussi bien la traversée de répertoire que l'injection dans l'en-tête
Content-Disposition.
Définir TOOL_PASSWORD place toutes les routes derrière une authentification
HTTP Basic, sauf /healthz qui reste joignable pour la supervision. La
comparaison des identifiants passe par secrets.compare_digest des deux côtés,
sans court-circuit sur le nom d'utilisateur.
Deux limites à connaître :
- HTTP Basic transmet les identifiants à chaque requête. N'activez cette protection que derrière HTTPS, ce qui est le cas par défaut sur O2Switch comme sur Railway.
- L'authentification est appliquée par l'application. Les fichiers servis
directement par le serveur web ne passent pas par elle — d'où le
deploy/o2switch.htaccess, sans lequel tout le dépôt reste lisible malgré un mot de passe correctement configuré.
Sur O2Switch, cPanel matérialise les variables d'environnement en écrivant dans
le .htaccess de l'application :
<IfModule Litespeed>
SetEnv TOOL_USERNAME …
SetEnv TOOL_PASSWORD …
</IfModule>Le mot de passe s'y trouve donc en clair, dans un fichier situé à la racine
du site. Les serveurs cPanel refusent déjà .htaccess en HTTP, et
deploy/o2switch.htaccess le refuse une seconde fois (<FilesMatch "^\.">).
À vérifier après tout changement de configuration, cPanel réécrivant ce fichier
à chaque modification de l'application :
curl -o /dev/null -w '%{http_code}\n' https://<domaine>/.htaccess # 403Sur Railway, les variables ne transitent pas par un fichier du site.
Tenir pymupdf à jour. C'est la mesure de sécurité qui compte le plus dans
la durée : ce composant analyse des PDF que l'outil n'a pas produits, et c'est
donc la principale surface d'attaque. MuPDF a un historique de correctifs
mémoire ; le reste du code ne manipule que des chaînes déjà validées.
# O2Switch
source ~/virtualenv/pdf2moodle/3.12/bin/activate
pip install -U pymupdf && pip freeze | grep -i pymupdf # reporter dans requirements.txt
cloudlinux-selector restart --interpreter python --app-root pdf2moodleSur Railway, il suffit de mettre à jour la version épinglée dans
requirements.txt et de pousser.
Après toute mise à jour, relancez la suite de tests : elle vérifie l'extraction des titres, la conversion et le rendu réel du document, ce qui suffit à détecter une régression de PyMuPDF.
Mettre à jour le code déployé :
cd ~/pdf2moodle && git pull
cloudlinux-selector restart --interpreter python --app-root pdf2moodleLe .htaccess et le passenger_wsgi.py généré ne sont pas suivis par git : un
git pull les préserve. Vérifiez tout de même après coup que les règles de
deploy/o2switch.htaccess sont toujours en place — cPanel réécrit ce fichier
lors des changements de configuration de l'application.