Aller au contenu principal

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/completions que celui de la Vision (entrée image), avec une clé qui possède le scope llm.

À propos de l'envoi direct de PDF

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èleTok entréeTok sortiesAP/pageRemarques
gemini-3.6-flash1,7801,2257.6301Recommandé. Lit aussi le texte à l'intérieur des captures d'écran
gpt-5.6-sol2,7846848.91,017Tout aussi fidèle. Cher
gpt-51,7106427.1203A tendance à inventer des titres absents de la page
gpt-5-mini2,78475311.669Transforme les numéros des repères en titres
gpt-4o1,9063346.5222Corrige silencieusement les coquilles de l'original
gpt-4o-mini37,6364156.3191Environ 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 prix sont approximatifs et les tarifs en AP sont révisés

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, low ne 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 -layout seul suffit. Aucune API nécessaire

Dépannage​

SymptômeCause et solution
403 ForbiddenLa clé n'a pas le scope llm -> demandez à la personne qui l'a émise de l'ajouter
404 Not Found sur /v1/responsesL'API Responses n'est pas prise en charge. Utilisez /v1/chat/completions
Cloudflare error 1010Le User-Agent par défaut de votre client HTTP a été signalé comme un bot. Définissez un UA explicite
Sortie enveloppée dans ```markdownPrécisez dans le prompt : pas de préambule, ne renvoyer que le Markdown lui-même
Descriptions de figures trop maigresVérifiez que detail: "high" est défini et que la résolution (dpi) n'est pas trop basse

Voir aussi​

© 2026 AICU Inc.