Guide du traitement des PDF et des documents
Transformez des PDF mis en page en Markdown structuré. Utile pour la relecture, la réutilisation de contenus existants et pour rendre des manuels illustrés consultables par recherche. Il n'y a pas d'endpoint dédié : vous envoyez vos requêtes au même
POST /v1/chat/completionsque celui de la Vision (entrée image), avec une clé qui possède le scopellm.
input_file de l'API Responses d'OpenAI (qui accepte un PDF tel quel) n'est pas encore pris en charge
sur api.aicu.ai — nous étudions la question.
Pour l'instant, suivez les étapes de cette page : convertissez d'abord le PDF en images de pages, puis transmettez-les en tant que image_url.
Le principe : ne pas faire retranscrire le modèle
Si vous n'avez besoin que du texte d'un PDF, pdftotext fait déjà le travail : gratuit, rapide et
exact au caractère près. Il ne manque que deux choses :
- L'ordre de lecture — avec plusieurs colonnes, il saute de l'une à l'autre. Les caractères sont corrects, mais le résultat ne se lit pas comme un texte suivi
- Le contenu des figures — vous n'obtenez exactement aucun caractère des captures d'écran et des images d'exemple
Transmettez donc à la fois la couche texte et l'image de la page, et ne laissez au modèle que deux tâches : rétablir l'ordre de lecture et décrire les figures.
input = page image (PNG) + pdftotext text layer
output = structured Markdown (figures described in place as [Figure])
Comme le corps du texte provient de la couche texte, la précision ne se dégrade pas et le modèle produit moins de tokens de sortie. C'est moins cher et plus précis que de ne fournir que les images des pages en demandant une retranscription complète.
Installation
brew install ghostscript poppler # gs (rendu des pages) + pdftotext/pdfinfo
pip install openai
Exemple minimal (une page)
import base64, os, subprocess, tempfile
from pathlib import Path
from openai import OpenAI
client = OpenAI(
api_key=os.environ["AICU_API_KEY"],
base_url="https://api.aicu.ai/v1",
)
SYSTEM = """You are an expert at reading typeset pages and producing structured Markdown.
Your input is a page image plus text mechanically extracted with pdftotext.
The text is character-accurate, but its reading order is broken because the page is multi-column.
1. Take the body text from the supplied text and put it back in the correct reading order. Never rewrite the characters.
2. Mark headings with ## / ### according to their level.
3. Where a figure or screenshot appears, insert the following at that position:
> **[Figure]** What it shows (1-2 sentences). Include any important text or UI elements visible in it.
4. Turn numbered callouts in procedures into an ordered list in the body text.
5. Render boxed notes and asides as `> **[Note]**`.
6. Do not output typesetting artifacts such as page numbers, running heads, or crop marks.
7. No preamble — return the Markdown itself and nothing else."""
def page_to_markdown(pdf: str, page: int, model: str = "gemini-3.6-flash") -> str:
# 1) Rendre la page en PNG (environ 120 dpi suffisent largement)
with tempfile.TemporaryDirectory() as tmp:
png = Path(tmp) / "page.png"
subprocess.run([
"gs", "-sDEVICE=png16m", "-r120",
f"-dFirstPage={page}", f"-dLastPage={page}",
"-dNOPAUSE", "-dQUIET", "-dBATCH", f"-sOutputFile={png}", pdf,
], check=True)
b64 = base64.b64encode(png.read_bytes()).decode()
# 2) Couche texte de la même page
text = subprocess.run(
["pdftotext", "-layout", "-f", str(page), "-l", str(page), pdf, "-"],
check=True, capture_output=True,
).stdout.decode("utf-8", "replace").strip()
# 3) Envoyer les deux
res = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": [
{"type": "text",
"text": f"Text layer:\n```\n{text}\n```\n\n"
f"Look at the page image and return Markdown following the rules above."},
{"type": "image_url", "image_url": {
"url": f"data:image/png;base64,{b64}", "detail": "high",
}},
]},
],
)
return res.choices[0].message.content
print(page_to_markdown("manual.pdf", 1))
Traiter toutes les pages (parallélisme + cache + total des coûts)
Pour les PDF longs, mettez chaque page en cache afin qu'un échec en cours de route coûte peu à relancer.
Lisez l'en-tête X-AICU-AP-Cost et vous pourrez totaliser le coût réel au fur et à mesure.
import base64, concurrent.futures as futures, os, re, subprocess, tempfile, sys
from pathlib import Path
from openai import OpenAI
MODEL = "gemini-3.6-flash"
SYSTEM = "..." # même prompt système que dans « Exemple minimal » ci-dessus
client = OpenAI(api_key=os.environ["AICU_API_KEY"], base_url="https://api.aicu.ai/v1")
def page_count(pdf: str) -> int:
out = subprocess.run(["pdfinfo", pdf], check=True, capture_output=True)
m = re.search(r"^Pages:\s+(\d+)", out.stdout.decode(), re.M)
return int(m.group(1)) if m else 0
def one_page(pdf: str, page: int, cache: Path):
hit = cache / f"p{page:03d}.md"
if hit.exists(): # ne pas rappeler l'API lors d'une relance
return page, hit.read_text(encoding="utf-8"), 0
with tempfile.TemporaryDirectory() as tmp:
png = Path(tmp) / "p.png"
subprocess.run(["gs", "-sDEVICE=png16m", "-r120",
f"-dFirstPage={page}", f"-dLastPage={page}",
"-dNOPAUSE", "-dQUIET", "-dBATCH",
f"-sOutputFile={png}", pdf], check=True)
b64 = base64.b64encode(png.read_bytes()).decode()
text = subprocess.run(["pdftotext", "-layout", "-f", str(page), "-l", str(page), pdf, "-"],
check=True, capture_output=True).stdout.decode("utf-8", "replace")
# passer par raw_response pour lire les AP réellement facturés
raw = client.chat.completions.with_raw_response.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": [
{"type": "text", "text": f"Text layer:\n```\n{text}\n```"},
{"type": "image_url", "image_url": {
"url": f"data:image/png;base64,{b64}", "detail": "high"}},
]},
],
)
ap = int(raw.headers.get("x-aicu-ap-cost", 0) or 0)
md = raw.parse().choices[0].message.content
hit.write_text(md, encoding="utf-8")
return page, md, ap
def pdf_to_markdown(pdf: str, workers: int = 4) -> str:
total = page_count(pdf)
cache = Path(".cache") / Path(pdf).stem
cache.mkdir(parents=True, exist_ok=True)
results, ap_total = {}, 0
with futures.ThreadPoolExecutor(max_workers=workers) as pool:
jobs = {pool.submit(one_page, pdf, p, cache): p for p in range(1, total + 1)}
for f in futures.as_completed(jobs):
try:
page, md, ap = f.result()
except Exception as e:
print(f" p{jobs[f]} failed: {e}", file=sys.stderr)
continue
results[page] = md
ap_total += ap
print(f" p{page} done")
# 10 000 AP = 1 $ (approximatif ; le tarif en AP peut être révisé)
print(f"total {ap_total:,} AP ~= ${ap_total / 10000:,.4f}")
return "\n\n".join(results[p] for p in sorted(results))
Path("out.md").write_text(pdf_to_markdown("manual.pdf"), encoding="utf-8")
Choisir un modèle
Mesuré sur une double page d'un livre technique (1535x1114 px, deux colonnes, 8 repères numérotés,
5 captures d'écran, detail: high).
| Modèle | Tok entrée | Tok sortie | s | AP/page | Remarques |
|---|---|---|---|---|---|
gemini-3.6-flash | 1,780 | 1,225 | 7.6 | 301 | Recommandé. Lit aussi le texte à l'intérieur des captures d'écran |
gpt-5.6-sol | 2,784 | 684 | 8.9 | 1,017 | Tout aussi fidèle. Cher |
gpt-5 | 1,710 | 642 | 7.1 | 203 | A tendance à inventer des titres absents de la page |
gpt-5-mini | 2,784 | 753 | 11.6 | 69 | Transforme les numéros des repères en titres |
gpt-4o | 1,906 | 334 | 6.5 | 222 | Corrige silencieusement les coquilles de l'original |
gpt-4o-mini | 37,636 | 415 | 6.3 | 191 | Environ 20 fois plus de tokens d'image |
(Les nombres de tokens et les AP ont été mesurés lors d'exécutions séparées. En production, avec un prompt ajusté,
gemini-3.6-flash est revenu à environ 761 AP ~= 0,076 $ par page.)
Les chiffres en AP du tableau et les montants en dollars de cette page sont des estimations au moment
de la mesure. L'AP est libellé en USD à raison de 10 000 AP = 1 $ (1 AP = 0,0001 $), mais le
tarif en AP est susceptible d'être révisé. Pour vos estimations, vérifiez toujours les valeurs actuelles via
GET /v1/chat/models et les tarifs de l'API LLM.
Mise en garde 1 : gpt-4o-mini n'est pas bon marché pour les images
Pour la même image, il consomme environ 20 fois plus de tokens que les autres modèles. C'est voulu : un multiplicateur de tokens élevé compensé par un prix unitaire bas, de sorte que ce que vous payez revient à peu près au même. Mais ces tokens sont réellement consommés dans la fenêtre de contexte : une conception qui regroupe plusieurs pages dans une seule requête va la faire déborder.
Mise en garde 2 : pour la relecture, vérifiez que l'original est préservé
La page de test contenait Colaub (une coquille pour Colab).
gpt-4o l'a « corrigée » en Colab dans sa sortie.
Un comportement serviable, mais fatal si l'objectif est la relecture : le processus censé trouver les coquilles les efface. Cela s'est produit même avec un « ne jamais réécrire les caractères » explicite dans le prompt ; si votre cas d'usage est la relecture, testez donc sur une page contenant des coquilles connues avant de choisir un modèle.
Pour mémoire, gemini-3.6-flash, gpt-5.6-sol, gpt-5 et gpt-5-mini ont tous conservé la coquille.
Conseils
- Environ 120 dpi suffisent. Monter plus haut ne fait qu'ajouter des tuiles et du coût ; la précision bouge à peine
- Utilisez
detail: high. Si vous voulez que le modèle lise le contenu des figures,lowne suffira pas - Mettez en cache page par page. Un PDF long finira par échouer en cours de route
- Ne coupez pas les doubles pages. Avec la double page entière, le modèle peut suivre un texte qui court sur les deux pages
- Si vous n'avez pas besoin des figures et ne voulez que le corps du texte,
pdftotext -layoutseul suffit. Aucune API nécessaire
Dépannage
| Symptôme | Cause et solution |
|---|---|
403 Forbidden | La clé n'a pas le scope llm -> demandez à la personne qui l'a émise de l'ajouter |
404 Not Found sur /v1/responses | L'API Responses n'est pas prise en charge. Utilisez /v1/chat/completions |
Cloudflare error 1010 | Le User-Agent par défaut de votre client HTTP a été signalé comme un bot. Définissez un UA explicite |
Sortie enveloppée dans ```markdown | Précisez dans le prompt : pas de préambule, ne renvoyer que le Markdown lui-même |
| Descriptions de figures trop maigres | Vérifiez que detail: "high" est défini et que la résolution (dpi) n'est pas trop basse |
Voir aussi
- Guide Vision (entrée image) — pour analyser une seule image
- API LLM — liste des modèles et tarifs en AP (
GET /v1/chat/models)
© 2026 AICU Inc.