Extraire les champs d’une facture
Récupérer le numéro, la date et le montant dû d’une facture, quelle que soit la mise en page du fournisseur.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0, et la réforme y est pour beaucoup. Depuis le 1er septembre 2026, une facture entre entreprises françaises arrive en UBL, en CII ou en Factur-X par une plateforme agréée : le numéro, la date et les montants sont dans le fichier, nommés par la norme EN 16931, et les lire coûte vingt lignes. Sur cette moitié-là du courrier, aucun modèle n’a rien à apporter, et la norme offre même le contrôle que les autres niveaux n’ont pas : total TTC = total HT + TVA, sa règle BR-CO-15, qui attrape pour rien la lecture bien formée et fausse. Reste le résidu — fournisseurs hors de France, tickets, arriéré, factures d’avant la réforme —, et c’est là que l’extraction sert encore : l’ancrage sur les libellés tient pour un fournisseur et tombe au suivant, ce que l’extrait montre et ce que la même fonction signale en disant par quelle porte la réponse est passée. N1 rattrape le changement de gabarit, mais prend pour le montant dû une mention légale que toute facture entre professionnels porte en bas de page, et l’annoter n’y change rien. N3 lit ce que N1 rate, au prix de vos factures chez un tiers et d’une réponse dont la forme se vérifie mais pas la véracité. Montez-y pour le résidu si son volume le justifie, en sachant qu’il se réduit à mesure que la réforme s’applique.
Lecture du fichier structuré, puis ancrage sur les libellés pour ce qui reste
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Lire une facture : le fichier structuré d'abord, la page seulement s'il n'y
en a pas.
Niveau N0. Aucun modèle, aucun jeu d'entraînement, aucun service.
Depuis le 1er septembre 2026, toute entreprise en France doit pouvoir
recevoir ses factures sous format électronique structuré, par l'intermédiaire
d'une plateforme agréée, et le socle minimum de formats est UBL, CII et
Factur-X, tous trois porteurs de la norme sémantique européenne EN 16931.
Cela change l'ordre des opérations : quand la facture arrive structurée, ses
champs ne sont pas extraits, ils sont lus, et la réponse est exacte.
L'extraction est pour ce qui reste — un fournisseur hors de France, un ticket
de caisse, une facture d'avant la réforme, l'arriéré.
Ce fichier a donc deux portes et une règle : dire par quelle porte la réponse
est passée. Un nombre lu dans du XML et un nombre lu sur une ligne de texte
ne valent pas la même chose, et l'appelant doit pouvoir les distinguer.
Sortir le XML d'un PDF Factur-X n'est pas le travail de ce fichier : c'est une
pièce jointe dans le PDF, et n'importe quelle bibliothèque PDF la liste. Ce
qui arrive ici, c'est le XML lui-même, ou le texte d'une page.
"""
import re
import xml.etree.ElementTree as ElementTree
# The five fields, named by their business term in EN 16931, then by the local
# name of the element and of its parent in each of the two syntaxes. One pair
# is enough to find them without carrying a page of namespace declarations.
SUMMATION = "SpecifiedTradeSettlementHeaderMonetarySummation"
STRUCTURED = {
# BT-1, BT-2: /rsm:ExchangedDocument/ram:ID and /ram:IssueDateTime/udt:DateTimeString
"invoice_number": (("ExchangedDocument", "ID"), ("Invoice", "ID")),
"date": (("IssueDateTime", "DateTimeString"), ("Invoice", "IssueDate")),
# BT-109, BT-110, BT-112
"total_excluding_vat": ((SUMMATION, "TaxBasisTotalAmount"), ("LegalMonetaryTotal", "TaxExclusiveAmount")),
"vat": ((SUMMATION, "TaxTotalAmount"), ("TaxTotal", "TaxAmount")),
"total": ((SUMMATION, "GrandTotalAmount"), ("LegalMonetaryTotal", "TaxInclusiveAmount")),
}
class UnreadableInvoice(Exception):
"""The document is neither of the two syntaxes this reads."""
def read_structured(xml: bytes) -> dict:
"""
Read the fields of a UBL or CII invoice, and check the one sum EN 16931
makes a rule.
That rule, BR-CO-15, is the cheapest guard there is against a reading that
is well-formed and wrong: total with VAT = total without VAT + VAT. It
does not apply to the extended profile, so a false `totals_agree` is a
reason to look, not a reason to reject.
"""
values = {}
root = ElementTree.fromstring(xml)
for parent in root.iter():
for child in parent:
values.setdefault((_local(parent.tag), _local(child.tag)), (child.text or "").strip())
syntax = 0 if _local(root.tag) == "CrossIndustryInvoice" else 1
if syntax == 1 and _local(root.tag) != "Invoice":
raise UnreadableInvoice(f"neither CII nor UBL: {_local(root.tag)}")
fields = {name: values.get(keys[syntax]) for name, keys in STRUCTURED.items()}
if fields["invoice_number"] is None:
raise UnreadableInvoice("no invoice number in the document")
return {
"source": "structured",
"invoice_number": fields["invoice_number"],
"date": _date(fields["date"]),
**{name: _amount(fields[name]) for name in ("total_excluding_vat", "vat", "total")},
"totals_agree": _totals_agree(fields),
}
def _local(tag: str) -> str:
"""The name of an element without its namespace."""
return tag.rpartition("}")[2]
def _date(raw: str | None) -> str | None:
"""CII writes 20260915, UBL writes 2026-09-15; the caller gets one shape."""
if not raw:
return None
digits = re.sub(r"\D", "", raw)
return f"{digits[:4]}-{digits[4:6]}-{digits[6:8]}" if len(digits) >= 8 else None
def _amount(raw: str | None) -> float | None:
"""An amount in the two syntaxes is a decimal point and nothing else."""
try:
return round(float(raw), 2)
except (TypeError, ValueError):
return None
def _totals_agree(fields: dict) -> bool | None:
"""None when the invoice does not carry the three amounts the rule needs."""
amounts = [_amount(fields[name]) for name in ("total_excluding_vat", "vat", "total")]
if any(value is None for value in amounts):
return None
without, vat, total = amounts
return abs(without + vat - total) < 0.005
# ---------------------------------------------------------------------------
# The other door: a page of text, for what the reform does not cover
# ---------------------------------------------------------------------------
# An amount, signed or not, with two decimals: "1 234,56" and "1.234,56" in
# French, "1,234.56" in English. Exactly three digits per group keeps "2 38,50"
# apart, a quantity then a price; "2 380,50" still reads as one number. At most
# four groups, up to the trillions, so that a long run of digit groups is read
# in linear time.
AMOUNT = r"[-\u2212]?(?<![\d.,])\d{1,3}(?:(?:[\s.]\d{3}){0,4},|(?:[\s,]\d{3}){0,4}\.)\d{2}(?![.,]?\d)"
DATE = r"\d{1,2}/\d{1,2}/\d{2,4}"
REFERENCE = r"\b(?:[A-Za-z]{1,3}[-/])?\d[\dA-Za-z/-]{3,}"
def parse_amount(raw: str) -> float:
"""Turn a written amount into a number the caller can compute with."""
# An AMOUNT match always ends on two decimals: whatever the separators, the
# last two digits are the cents.
cents = int(re.sub(r"\D", "", raw)) / 100
return -cents if raw.lstrip()[:1] in ("-", "\u2212") else cents
# Per field: the labels to look for, most specific first, the pattern the value
# must match after the label, and how to read the match.
FIELDS = {
"invoice_number": (("facture n°", "facture no", "n° facture"), REFERENCE, str.strip),
"date": (("date",), DATE, str.strip),
"total": (("total ttc", "montant ttc", "total"), AMOUNT, parse_amount),
}
def find_after_label(text: str, labels: tuple[str, ...], pattern: str) -> str | None:
"""
First value matching `pattern` after one of `labels`, on the same line.
A label that appears on a line holding no value is skipped rather than
accepted, because a column heading is a label too.
"""
# Every run of spaces, a non-breaking one included, reads as one space.
lines = [re.sub(r"\s+", " ", line) for line in text.splitlines()]
for label in labels:
for line in lines:
position = line.lower().find(label)
if position == -1:
continue
match = re.search(pattern, line[position + len(label):])
if match:
return match.group(0)
return None
def extract_fields(text: str) -> dict:
"""
Read the invoice number, the date and the total from extracted text.
Three fields, and no arithmetic to check them against: what the structured
door gets for nothing, this one cannot have.
"""
fields = {"source": "text"}
for name, (labels, pattern, read) in FIELDS.items():
raw = find_after_label(text, labels, pattern)
fields[name] = read(raw) if raw is not None else None
return {**fields, "total_excluding_vat": None, "vat": None, "totals_agree": None}
def read_invoice(document) -> dict:
"""
One invoice, whichever way it arrived, and the door it came through.
`document` is the structured file when the platform delivered one — bytes
of UBL or CII, or the `factur-x.xml` your PDF library handed you — and the
text of the page when it did not. Nothing here decides that for you: the
type of what you pass in does.
"""
if isinstance(document, (bytes, bytearray)):
return read_structured(bytes(document))
return extract_fields(document)JavaScript
/**
* Lire une facture : le fichier structuré d'abord, la page seulement s'il n'y
* en a pas.
*
* Niveau N0. Aucun modèle, aucun jeu d'entraînement, aucun service.
*
* Depuis le 1er septembre 2026, toute entreprise en France doit pouvoir
* recevoir ses factures sous format électronique structuré, par l'intermédiaire
* d'une plateforme agréée, et le socle minimum de formats est UBL, CII et
* Factur-X, tous trois porteurs de la norme sémantique européenne EN 16931.
* Cela change l'ordre des opérations : quand la facture arrive structurée, ses
* champs ne sont pas extraits, ils sont lus, et la réponse est exacte.
* L'extraction est pour ce qui reste — un fournisseur hors de France, un ticket
* de caisse, une facture d'avant la réforme, l'arriéré.
*
* Ce fichier a donc deux portes et une règle : dire par quelle porte la réponse
* est passée. Un nombre lu dans du XML et un nombre lu sur une ligne de texte
* ne valent pas la même chose, et l'appelant doit pouvoir les distinguer.
*
* Sortir le XML d'un PDF Factur-X n'est pas le travail de ce fichier : c'est une
* pièce jointe dans le PDF, et n'importe quelle bibliothèque PDF la liste. Ce
* qui arrive ici, c'est le XML lui-même, ou le texte d'une page.
*/
import { XMLParser } from 'fast-xml-parser';
// The five fields, named by their business term in EN 16931, then by the local
// name of the element and of its parent in each of the two syntaxes. One pair
// is enough to find them without carrying a page of namespace declarations.
const SUMMATION = 'SpecifiedTradeSettlementHeaderMonetarySummation';
const STRUCTURED = {
// BT-1, BT-2: /rsm:ExchangedDocument/ram:ID and /ram:IssueDateTime/udt:DateTimeString
invoice_number: [['ExchangedDocument', 'ID'], ['Invoice', 'ID']],
date: [['IssueDateTime', 'DateTimeString'], ['Invoice', 'IssueDate']],
// BT-109, BT-110, BT-112
total_excluding_vat: [[SUMMATION, 'TaxBasisTotalAmount'], ['LegalMonetaryTotal', 'TaxExclusiveAmount']],
vat: [[SUMMATION, 'TaxTotalAmount'], ['TaxTotal', 'TaxAmount']],
total: [[SUMMATION, 'GrandTotalAmount'], ['LegalMonetaryTotal', 'TaxInclusiveAmount']],
};
export class UnreadableInvoice extends Error {}
// Namespaces dropped, attributes ignored, values left as written: an amount is
// read here and not by the parser.
const parser = new XMLParser({
ignoreAttributes: true,
removeNSPrefix: true,
parseTagValue: false,
ignoreDeclaration: true,
});
/**
* Read the fields of a UBL or CII invoice, and check the one sum EN 16931
* makes a rule.
*
* That rule, BR-CO-15, is the cheapest guard there is against a reading that
* is well-formed and wrong: total with VAT = total without VAT + VAT. It does
* not apply to the extended profile, so a false `totals_agree` is a reason to
* look, not a reason to reject.
*/
export function readStructured(xml) {
const document = parser.parse(String(xml));
const [rootName] = Object.keys(document);
const syntax = rootName === 'CrossIndustryInvoice' ? 0 : 1;
if (syntax === 1 && rootName !== 'Invoice') {
throw new UnreadableInvoice(`neither CII nor UBL: ${rootName}`);
}
const values = new Map();
collect(document, '', values);
const fields = Object.fromEntries(
Object.entries(STRUCTURED).map(([name, keys]) => [name, values.get(keys[syntax].join('/'))]),
);
if (fields.invoice_number === undefined) throw new UnreadableInvoice('no invoice number in the document');
return {
source: 'structured',
invoice_number: fields.invoice_number,
date: readDate(fields.date),
total_excluding_vat: readAmount(fields.total_excluding_vat),
vat: readAmount(fields.vat),
total: readAmount(fields.total),
totals_agree: totalsAgree(fields),
};
}
/** Every (parent, child) pair of the tree, first one wins, as in Python. */
function collect(node, name, values) {
for (const [child, value] of Object.entries(node)) {
const first = Array.isArray(value) ? value[0] : value;
if (first !== null && typeof first === 'object') collect(first, child, values);
else if (!values.has(`${name}/${child}`)) values.set(`${name}/${child}`, String(first ?? '').trim());
}
}
/** CII writes 20260915, UBL writes 2026-09-15; the caller gets one shape. */
function readDate(raw) {
const digits = String(raw ?? '').replace(/\D/g, '');
return digits.length >= 8 ? `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}` : null;
}
/** An amount in the two syntaxes is a decimal point and nothing else. */
function readAmount(raw) {
const value = Number(raw);
return raw === undefined || raw === '' || Number.isNaN(value) ? null : Math.round(value * 100) / 100;
}
/** Null when the invoice does not carry the three amounts the rule needs. */
function totalsAgree(fields) {
const [without, vat, total] = ['total_excluding_vat', 'vat', 'total'].map((n) => readAmount(fields[n]));
if (without === null || vat === null || total === null) return null;
return Math.abs(without + vat - total) < 0.005;
}
// ---------------------------------------------------------------------------
// The other door: a page of text, for what the reform does not cover
// ---------------------------------------------------------------------------
// An amount, signed or not, with two decimals: "1 234,56" and "1.234,56" in French,
// "1,234.56" in English. Exactly three digits per group keeps "2 38,50" apart,
// a quantity then a price; "2 380,50" still reads as one number. At most four
// groups, up to the trillions, so that a long run of digit groups is read in
// linear time.
export const AMOUNT = /[-−]?(?<![\d.,])\d{1,3}(?:(?:[\s.]\d{3}){0,4},|(?:[\s,]\d{3}){0,4}\.)\d{2}(?![.,]?\d)/;
const DATE = /\d{1,2}\/\d{1,2}\/\d{2,4}/;
const REFERENCE = /\b(?:[A-Za-z]{1,3}[-/])?\d[\dA-Za-z/-]{3,}/;
/** Turn a written amount into a number the caller can compute with. */
export function parseAmount(raw) {
// An AMOUNT match always ends on two decimals: whatever the separators, the
// last two digits are the cents.
const cents = Number(raw.replace(/\D/g, '')) / 100;
return ['-', '−'].includes(raw.trimStart()[0]) ? -cents : cents;
}
const trim = (raw) => raw.trim();
// Per field: the labels to look for, most specific first, the pattern the value
// must match after the label, and how to read the match.
const FIELDS = {
invoice_number: [['facture n°', 'facture no', 'n° facture'], REFERENCE, trim],
date: [['date'], DATE, trim],
total: [['total ttc', 'montant ttc', 'total'], AMOUNT, parseAmount],
};
/**
* First value matching `pattern` after one of `labels`, on the same line.
*
* A label that appears on a line holding no value is skipped rather than
* accepted, because a column heading is a label too.
*/
export function findAfterLabel(text, labels, pattern) {
// Every run of spaces, a non-breaking one included, reads as one space.
const lines = text.split(/\r?\n/).map((line) => line.replace(/\s+/g, ' '));
for (const label of labels) {
for (const line of lines) {
const position = line.toLowerCase().indexOf(label);
if (position === -1) continue;
const match = line.slice(position + label.length).match(pattern);
if (match) return match[0];
}
}
return null;
}
/**
* Read the invoice number, the date and the total from extracted text.
*
* Three fields, and no arithmetic to check them against: what the structured
* door gets for nothing, this one cannot have.
*/
export function extractFields(text) {
const fields = { source: 'text' };
for (const [name, [labels, pattern, read]] of Object.entries(FIELDS)) {
const raw = findAfterLabel(text, labels, pattern);
fields[name] = raw === null ? null : read(raw);
}
return { ...fields, total_excluding_vat: null, vat: null, totals_agree: null };
}
/**
* One invoice, whichever way it arrived, and the door it came through.
*
* `document` is the structured file when the platform delivered one — bytes of
* UBL or CII, or the `factur-x.xml` your PDF library handed you — and the text
* of the page when it did not. Nothing here decides that for you: the type of
* what you pass in does.
*/
export function readInvoice(document) {
if (typeof document !== 'string') return readStructured(new TextDecoder().decode(document));
return extractFields(document);
}Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : la facture ne quitte pas votre infrastructure
- Une facture d’entrepreneur individuel porte des données personnelles, traitées ici sur votre seule infrastructure
Point de rupture
La porte structurée n’a pas de point de rupture : ce qui est écrit dans le XML est lu tel quel, et la règle BR-CO-15 de la norme — total TTC = total HT + TVA — vérifie le tout pour rien. C’est l’autre porte qui casse, et elle casse sur le fournisseur suivant. Il ne range pas sa page comme celui pour lequel les règles ont été écrites. Il écrit « N° » là où le premier écrivait « Facture n° », date en toutes lettres, et appelle le montant dû « NET A PAYER » : les trois champs tombent. Deux tombent à vide, et cela se voit. Le troisième est le pire : le total revient bien formé et faux, parce que « Sous-total » contient « total ».
Quand passer au niveau suivant
Votre résidu ne se réduit pas : des factures sans fichier structuré arrivent de fournisseurs que vous n’aviez pas prévus, et vous rouvrez la liste des libellés pour chacun.
Les quatre options
- RecommandéN0Lecture du fichier structuré, puis ancrage sur les libellés pour ce qui resteGratuit · Instantané
- N1Traits de position et de mise en forme, puis classifieur de lignesnégligeable · Instantané
- N2Niveau sans objet ici
- N3Extraction structurée par modèle généraliste multimodalélevé · Plus d’une seconde
Les autres options
N1Traits de position et de mise en forme, puis classifieur de lignesnégligeable · Instantané
Traits de position et de mise en forme, puis classifieur de lignes
Modèle classique légerN1négligeableInstantané
Python
"""
Étiqueter chaque ligne de la facture, puis lire la valeur dans la ligne.
Niveau N1. N0 demandait « que dit cette ligne ». Celui-ci demande « où se
trouve cette ligne, et à quoi ressemble-t-elle » : à quelle hauteur de page,
quelle indentation, combien de mots, combien de montants, et si la valeur pend
du côté droit.
Ces traits survivent à un changement de fournisseur, ce que les libellés de N0
ne font justement pas. L'entraînement tient en quelques dizaines de lignes
étiquetées, le modèle pèse quelques kilooctets, et rien ne se télécharge.
"""
import re
from sklearn.linear_model import LogisticRegression
AMOUNT = r"\d{1,3}(?:[\s.]\d{3})*[,.]\d{2}"
# Month names in any case, accents typed or not.
MONTHS = "janvier|f[ée]vrier|mars|avril|mai|juin|juillet|ao[ûu]t|septembre|octobre|novembre|d[ée]cembre"
DATE = re.compile(rf"\d{{1,2}}/\d{{1,2}}/\d{{2,4}}|\d{{1,2}}\s+(?:{MONTHS})\s+\d{{4}}", re.I)
REFERENCE = r"\b(?:[A-Za-z]{1,3}[-/])?\d[\dA-Za-z/-]{3,}"
def page_lines(text: str) -> list[str]:
"""The lines that carry something, indentation kept: it is a feature."""
return [line for line in text.splitlines() if line.strip()]
def line_features(line: str, index: int, count: int) -> list[float]:
"""Where the line sits and what it looks like. Never what it says."""
text = line.strip()
amounts = re.findall(AMOUNT, text)
letters = "".join(c for c in text if c.isalpha())
return [
index / max(count - 1, 1), # how far down the page
1.0 if index == count - 1 else 0.0, # the very last line
min(len(line) - len(line.lstrip()), 40) / 40, # indentation
min(len(text.split()), 12) / 12, # how wordy
sum(c.isdigit() for c in text) / len(text), # digit share
min(len(amounts), 3) / 3, # how many amounts
1.0 if re.search(DATE, text) else 0.0,
1.0 if re.search(REFERENCE, text) else 0.0,
# A value hanging on the right of the line, the way a totals block does.
1.0 if amounts and text.rindex(amounts[-1]) > len(text) / 2 else 0.0,
1.0 if letters and letters.isupper() else 0.0,
]
def train(documents: list[str], labels: list[list[str]]):
"""`labels` carries one label per non-blank line of each document."""
rows, targets = [], []
for document, document_labels in zip(documents, labels):
lines = page_lines(document)
rows += [line_features(line, i, len(lines)) for i, line in enumerate(lines)]
targets += list(document_labels)
model = LogisticRegression(class_weight="balanced", max_iter=1000)
model.fit(rows, targets)
return model
def _match(pattern: str):
"""Reads the first value of that shape out of a line, or nothing."""
def read(line):
found = re.search(pattern, line)
return found.group(0) if found else None
return read
def _amount(line):
"""The rightmost amount: an item line carries a quantity and a unit price."""
amounts = re.findall(AMOUNT, line)
if not amounts:
return None
cleaned = re.sub(r"[^\d,.]", "", amounts[-1])
# A comma means French spelling: the dots left are thousands separators.
if "," in cleaned:
cleaned = cleaned.replace(".", "").replace(",", ".")
return float(cleaned)
READERS = {"invoice_number": _match(REFERENCE), "date": _match(DATE), "total": _amount}
def extract_fields(model, text: str) -> dict:
"""
For each field, walk the lines from the most likely down.
The model points at a line; reading a date or an amount out of it is still
ours to do, and a line the model likes but that holds no value is not an
answer.
"""
lines = page_lines(text)
if not lines:
return {name: None for name in READERS}
rows = [line_features(line, i, len(lines)) for i, line in enumerate(lines)]
scores = model.predict_proba(rows)
classes = list(model.classes_)
fields = {}
for name, read in READERS.items():
column = classes.index(name)
ranked = sorted(range(len(lines)), key=lambda i: -scores[i][column])
fields[name] = next((v for v in (read(lines[i]) for i in ranked) if v is not None), None)
return fieldsJavaScript
/**
* Étiqueter chaque ligne de la facture, puis lire la valeur dans la ligne.
*
* Niveau N1. N0 demandait « que dit cette ligne ». Celui-ci demande « où se
* trouve cette ligne, et à quoi ressemble-t-elle » : à quelle hauteur de page,
* quelle indentation, combien de mots, combien de montants, et si la valeur pend
* du côté droit.
*
* Ces traits survivent à un changement de fournisseur, ce que les libellés de N0
* ne font justement pas. L'entraînement tient en quelques dizaines de lignes
* étiquetées, et la régression logistique est assez courte pour être lue plutôt
* qu'importée.
*/
const AMOUNT = /\d{1,3}(?:[\s.]\d{3})*[,.]\d{2}/g;
// Month names in any case, accents typed or not.
const MONTHS = 'janvier|f[ée]vrier|mars|avril|mai|juin|juillet|ao[ûu]t|septembre|octobre|novembre|d[ée]cembre';
const DATE = new RegExp(`\\d{1,2}/\\d{1,2}/\\d{2,4}|\\d{1,2}\\s+(?:${MONTHS})\\s+\\d{4}`, 'i');
const REFERENCE = /\b(?:[A-Za-z]{1,3}[-/])?\d[\dA-Za-z/-]{3,}/;
/** The lines that carry something, indentation kept: it is a feature. */
export function pageLines(text) {
return text.split('\n').filter((line) => line.trim() !== '');
}
/** Where the line sits and what it looks like. Never what it says. */
export function lineFeatures(line, index, count) {
const text = line.trim();
const amounts = text.match(AMOUNT) ?? [];
const letters = text.replace(/[^\p{L}]/gu, '');
const words = text.split(/\s+/).filter(Boolean);
return [
index / Math.max(count - 1, 1), // how far down the page
index === count - 1 ? 1 : 0, // the very last line
Math.min(line.length - line.trimStart().length, 40) / 40, // indentation
Math.min(words.length, 12) / 12, // how wordy
(text.match(/\d/g) ?? []).length / text.length, // digit share
Math.min(amounts.length, 3) / 3, // how many amounts
DATE.test(text) ? 1 : 0,
REFERENCE.test(text) ? 1 : 0,
// A value hanging on the right of the line, the way a totals block does.
amounts.length && text.lastIndexOf(amounts.at(-1)) > text.length / 2 ? 1 : 0,
letters && letters === letters.toUpperCase() ? 1 : 0,
];
}
/**
* One binary classifier per label, each trained on every line.
*
* Each line is weighted so that the lines carrying the label weigh, together,
* as much as the lines that do not: on a page, they are the few.
*/
function trainOne(rows, targets, label, epochs, rate) {
const wanted = targets.map((t) => (t === label ? 1 : 0));
const positives = wanted.reduce((a, b) => a + b, 0);
const weights = new Float64Array(rows[0].length);
let bias = 0;
for (let epoch = 0; epoch < epochs; epoch += 1) {
for (let i = 0; i < rows.length; i += 1) {
const balance = rows.length / (2 * (wanted[i] ? positives : rows.length - positives));
let z = bias;
for (let j = 0; j < weights.length; j += 1) z += weights[j] * rows[i][j];
const error = (1 / (1 + Math.exp(-z)) - wanted[i]) * balance;
for (let j = 0; j < weights.length; j += 1) weights[j] -= rate * error * rows[i][j];
bias -= rate * error;
}
}
return { weights, bias };
}
/** `labels` carries one label per non-blank line of each document. */
export function train(documents, labels, { epochs = 400, rate = 0.3 } = {}) {
const rows = [];
const targets = [];
documents.forEach((document, d) => {
const lines = pageLines(document);
lines.forEach((line, i) => rows.push(lineFeatures(line, i, lines.length)));
targets.push(...labels[d]);
});
const classes = [...new Set(targets)].sort();
return { classes, models: classes.map((c) => trainOne(rows, targets, c, epochs, rate)) };
}
function score(model, row) {
let z = model.bias;
for (let j = 0; j < row.length; j += 1) z += model.weights[j] * row[j];
return 1 / (1 + Math.exp(-z));
}
/** Reads the first value of that shape out of a line, or nothing. */
const matching = (pattern) => (line) => line.match(pattern)?.[0] ?? null;
/** The rightmost amount: an item line carries a quantity and a unit price. */
function amount(line) {
const amounts = line.match(AMOUNT);
if (!amounts) return null;
let cleaned = amounts.at(-1).replace(/[^\d,.]/g, '');
// A comma means French spelling: the dots left are thousands separators.
if (cleaned.includes(',')) cleaned = cleaned.replaceAll('.', '').replace(',', '.');
return Number(cleaned);
}
const READERS = { invoice_number: matching(REFERENCE), date: matching(DATE), total: amount };
/**
* For each field, walk the lines from the most likely down.
*
* The model points at a line; reading a date or an amount out of it is still
* ours to do, and a line the model likes but that holds no value is not an
* answer.
*/
export function extractFields(model, text) {
const lines = pageLines(text);
const rows = lines.map((line, i) => lineFeatures(line, i, lines.length));
const fields = {};
for (const [name, read] of Object.entries(READERS)) {
const column = model.classes.indexOf(name);
const ranked = rows
.map((row, i) => [score(model.models[column], row), i])
.sort((a, b) => b[0] - a[0]);
fields[name] = ranked.map(([, i]) => read(lines[i])).find((value) => value !== null) ?? null;
}
return fields;
}Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se mesure sur un échantillon, pas cas par cas
- Dépend d’une bibliothèque
- Empreinte faible
Ce que cela ajoute à votre périmètre réglementaire
- Traitement de factures sur votre infrastructure
- Le jeu d’entraînement contient des factures réelles : il entre dans votre registre des traitements si vous en tenez un
Point de rupture
L’indemnité forfaitaire de recouvrement, que toute facture entre professionnels porte en bas de page. C’est un montant, seul sur sa ligne, en bas et à droite : au regard des traits, elle ressemble davantage à un total que le total lui-même, et le classifieur la retient. Et l’annoter ne suffit pas : entraîné sur cette facture même, l’indemnité étiquetée comme une ligne sans champ, il la retient encore, parce que ses traits ne la distinguent pas du total.
Quand passer au niveau suivant
Un montant de bas de page passe pour le montant dû, l’ajouter annoté au jeu d’entraînement ne change pas la lecture, et le volume de ce résidu justifie de payer un appel par facture.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Un modèle de compréhension de document — LayoutLM et ses cousins — lit la ligne, ses voisines et leur place dans la page, et c’est bien le signal qui manque à N1. Mais il n’y a rien à publier ici : le point de contrôle de référence, `microsoft/layoutlmv3-base`, est un encodeur non affiné, sans étiquette de facture, et sa fiche le donne sous licence Creative Commons BY-NC-SA, c’est-à-dire hors usage commercial ; aucun point de contrôle affiné sur des factures, sous licence commerciale, ne s’impose ; et rien n’en lit l’équivalent en JavaScript. Un extrait qui ne tourne pas n’a pas sa place sur cette échelle. Ce que ce niveau apporterait vaut surtout pour le résidu que la réforme laisse — et ce résidu se réduit.
N3Extraction structurée par modèle généraliste multimodalélevé · Plus d’une seconde
Extraction structurée par modèle généraliste multimodal
API de LLM généralisteN3élevéPlus d’une seconde
Python
"""
Demander à un modèle généraliste multimodal de lire la facture.
Niveau N3. Le modèle reçoit une image de la page avec son texte, et l'image
garde ce que le texte extrait a perdu : la colonne dans laquelle se trouve un
montant.
Notez ce que le code doit faire et que N0 ne faisait pas : borner la taille de
ce qu'il envoie, réessayer après un échec, analyser une réponse qui n'est que
probablement du JSON, et vérifier la forme de ce qui revient. Cette plomberie
est le vrai coût de ce niveau, et c'est la part que vos tests doivent couvrir,
parce que le modèle lui-même n'est pas testable.
Une vérification, ici, ne porte pas sur la forme. Les trois montants sont
demandés, et pas seulement celui qui est dû, pour que la somme dont la norme
EN 16931 fait une règle — total TTC = total HT + TVA — puisse être recalculée
sur ce que le modèle a écrit. Elle n'attrape rien de ce que le modèle se
trompe systématiquement à faire, et elle attrape le chiffre lu sur la mauvaise
ligne, qui est l'échec dont parle cette fiche.
"""
from __future__ import annotations
import base64
import json
import math
PROMPT = (
"Read the invoice below and return its header fields.\n"
"Answer with JSON only: an object with the keys `invoice_number`, `date`,\n"
"`total_excluding_vat`, `vat` and `total`. `total` is the amount due, taxes\n"
"included, as a number.\n"
"Use null for a field the page does not carry.\n\n"
"Extracted text:\n{text}"
)
# The provider bills every token of the prompt: the text is capped before the
# call, in characters, not tokens. An image is billed by its dimensions, not its
# bytes, so its cap only bounds what is encoded and uploaded.
MAX_CHARACTERS = 8000
MAX_IMAGE_BYTES = 4_000_000
FIELDS = ("invoice_number", "date", "total_excluding_vat", "vat", "total")
AMOUNTS = ("total_excluding_vat", "vat", "total")
# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., image_url=..., temperature=...)` method.
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
class ExtractionUnavailable(Exception):
"""The provider could not be reached, or answered something unusable."""
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, image_url: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
# The page travels inside the message, as a data URL.
messages=[{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": image_url}},
]}],
temperature=temperature,
)
return response.choices[0].message.content
def extract_fields(text: str, page_image: bytes, client=None, *, attempts: int = 3) -> dict:
"""
Read the invoice fields from its text and a picture of the page.
`client` is injected so this function can be tested without a network call.
In production it defaults to a real provider client.
"""
if len(text) > MAX_CHARACTERS:
raise ValueError(f"text longer than {MAX_CHARACTERS} characters")
if not page_image or len(page_image) > MAX_IMAGE_BYTES:
raise ValueError(f"page image empty or larger than {MAX_IMAGE_BYTES} bytes")
client = client or ProviderClient()
image_url = "data:image/png;base64," + base64.b64encode(page_image).decode()
return _decode(_ask(client, text, image_url, attempts))
def _ask(client, text: str, image_url: str, attempts: int) -> str:
last_error: Exception | None = None
for _ in range(attempts):
try:
answer = client.complete(
prompt=PROMPT.format(text=text),
image_url=image_url,
# Temperature zero, because an amount that changes between two
# identical calls cannot be reconciled with anything.
temperature=0,
)
if isinstance(answer, str):
return answer
last_error = ValueError("the model returned no content") # a refusal: unusable, not empty
except Exception as error: # noqa: BLE001 - any provider failure is retried
last_error = error
raise ExtractionUnavailable(str(last_error))
def _decode(answer: str) -> dict:
"""
Turn the answer into the five fields, or refuse it.
Models like to wrap JSON in a code fence. That is noise, not an error, and
stripping it is cheaper than another call.
"""
stripped = answer.strip()
if stripped.startswith("```") and stripped.endswith("```") and stripped.count("```") == 2:
stripped = stripped[3:-3].removeprefix("json")
try:
parsed = json.loads(stripped)
except ValueError as error:
raise ExtractionUnavailable(f"the model did not answer with JSON: {error}")
if not isinstance(parsed, dict):
raise ExtractionUnavailable("the model answered something that is not an object")
fields = {field: parsed.get(field) for field in FIELDS}
for field in ("invoice_number", "date"):
if fields[field] is not None and not isinstance(fields[field], str):
raise ExtractionUnavailable(f"the model answered a {field} that is not text: {fields[field]!r}")
for field in AMOUNTS:
value = fields[field]
# An amount nobody can compute with is worse than none at all: it would
# travel down the pipeline looking like a number.
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value)):
raise ExtractionUnavailable(f"the model answered a {field} that is not a number: {value!r}")
return {**fields, "totals_agree": _totals_agree(fields)}
def _totals_agree(fields: dict) -> bool | None:
"""
BR-CO-15 recomputed on what the model wrote, or None if it wrote too little.
False is not a reason to throw the answer away: it is the one reason the
caller has to look at this invoice rather than the other four hundred.
"""
amounts = [fields[field] for field in AMOUNTS]
if any(value is None for value in amounts):
return None
without, vat, total = amounts
return abs(without + vat - total) < 0.005JavaScript
/**
* Demander à un modèle généraliste multimodal de lire la facture.
*
* Niveau N3. Le modèle reçoit une image de la page avec son texte, et l'image
* garde ce que le texte extrait a perdu : la colonne dans laquelle se trouve un
* montant.
*
* Notez ce que le code doit faire et que N0 ne faisait pas : borner la taille de
* ce qu'il envoie, réessayer après un échec, analyser une réponse qui n'est que
* probablement du JSON, et vérifier la forme de ce qui revient. Cette plomberie
* est le vrai coût de ce niveau, et c'est la part que vos tests doivent couvrir,
* parce que le modèle lui-même n'est pas testable.
*
* Une vérification, ici, ne porte pas sur la forme. Les trois montants sont
* demandés, et pas seulement celui qui est dû, pour que la somme dont la norme
* EN 16931 fait une règle — total TTC = total HT + TVA — puisse être recalculée
* sur ce que le modèle a écrit. Elle n'attrape rien de ce que le modèle se
* trompe systématiquement à faire, et elle attrape le chiffre lu sur la mauvaise
* ligne, qui est l'échec dont parle cette fiche.
*/
const PROMPT = [
'Read the invoice below and return its header fields.',
'Answer with JSON only: an object with the keys `invoice_number`, `date`,',
'`total_excluding_vat`, `vat` and `total`. `total` is the amount due, taxes',
'included, as a number.',
'Use null for a field the page does not carry.',
'',
'Extracted text:',
].join('\n');
// The provider bills every token of the prompt: the text is capped before the
// call, in characters (code points, as in Python), not tokens. An image is
// billed by its dimensions, not its bytes, so its cap only bounds what is
// encoded and uploaded.
export const MAX_CHARACTERS = 8000;
export const MAX_IMAGE_BYTES = 4_000_000;
const FIELDS = ['invoice_number', 'date', 'total_excluding_vat', 'vat', 'total'];
const AMOUNTS = ['total_excluding_vat', 'vat', 'total'];
// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, imageUrl, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, imageUrl, temperature }) {
const response = await sdk.chat.completions.create({
model,
// The page travels inside the message, as a data URL.
messages: [{ role: 'user', content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: imageUrl } },
] }],
temperature,
});
return response.choices[0].message.content;
},
};
}
export class ExtractionUnavailable extends Error {}
/**
* Read the invoice fields from its text and a picture of the page.
*
* @param {string} text the invoice, already extracted
* @param {Uint8Array} pageImage the rendered page; this snippet opens no file
* @param {object} options
* @param {{complete: Function}} [options.client] injected so this can be
* tested without a network call; defaults to a real provider client
* @param {number} [options.attempts]
*/
export async function extractFields(text, pageImage, { client, attempts = 3 } = {}) {
if (text.length > MAX_CHARACTERS && [...text].length > MAX_CHARACTERS) {
throw new RangeError(`text longer than ${MAX_CHARACTERS} characters`);
}
if (pageImage.length === 0 || pageImage.length > MAX_IMAGE_BYTES) {
throw new RangeError(`page image empty or larger than ${MAX_IMAGE_BYTES} bytes`);
}
client ??= await providerClient();
const imageUrl = `data:image/png;base64,${Buffer.from(pageImage).toString('base64')}`;
return decode(await ask(client, text, imageUrl, attempts));
}
async function ask(client, text, imageUrl, attempts) {
let lastError;
for (let i = 0; i < attempts; i += 1) {
try {
const answer = await client.complete({
prompt: `${PROMPT}\n${text}`,
imageUrl,
// Temperature zero, because an amount that changes between two
// identical calls cannot be reconciled with anything.
temperature: 0,
});
if (typeof answer === 'string') return answer;
lastError = new Error('the model returned no content'); // a refusal: unusable, not empty
} catch (error) {
lastError = error;
}
}
throw new ExtractionUnavailable(String(lastError));
}
/**
* Turn the answer into the three fields, or refuse it.
*
* Models like to wrap JSON in a code fence. That is noise, not an error, and
* stripping it is cheaper than another call.
*/
/**
* BR-CO-15 recomputed on what the model wrote, or null if it wrote too little.
*
* False is not a reason to throw the answer away: it is the one reason the
* caller has to look at this invoice rather than the other four hundred.
*/
function totalsAgree(fields) {
const [without, vat, total] = AMOUNTS.map((field) => fields[field]);
if (without === null || vat === null || total === null) return null;
return Math.abs(without + vat - total) < 0.005;
}
function decode(answer) {
const trimmed = answer.trim();
const fenced = trimmed.startsWith('```') && trimmed.endsWith('```') && trimmed.split('```').length === 3;
const stripped = fenced ? trimmed.slice(3, -3).replace(/^json/, '') : trimmed;
let parsed;
try {
parsed = JSON.parse(stripped);
} catch (error) {
throw new ExtractionUnavailable(`the model did not answer with JSON: ${error.message}`);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new ExtractionUnavailable('the model answered something that is not an object');
}
const fields = Object.fromEntries(FIELDS.map((field) => [field, parsed[field] ?? null]));
for (const field of ['invoice_number', 'date']) {
if (fields[field] !== null && typeof fields[field] !== 'string') {
throw new ExtractionUnavailable(`the model answered a ${field} that is not text: ${fields[field]}`);
}
}
for (const field of AMOUNTS) {
// An amount nobody can compute with is worse than none at all: it would
// travel down the pipeline looking like a number.
if (fields[field] !== null && (typeof fields[field] !== 'number' || !Number.isFinite(fields[field]))) {
throw new ExtractionUnavailable(`the model answered a ${field} that is not a number: ${fields[field]}`);
}
}
return { ...fields, totals_agree: totalsAgree(fields) };
}Ce que ça implique
- Vos données partent chez un tiers
- Résultat variable d’un appel à l’autre
- Difficile à tester
- Dépend d’un fournisseur externe
- Empreinte élevée
Ce que cela ajoute à votre périmètre réglementaire
- Transfert de factures à un sous-traitant, avec l’encadrement contractuel que cela suppose
- Localisation du traitement à vérifier auprès du fournisseur
- Ne vous dispense pas de vos obligations de conservation et de piste d’audit sur les pièces comptables
Point de rupture
Toutes les vérifications portent sur la forme de la réponse, aucune sur sa véracité. Un objet parfaitement valide dont le montant n’apparaît nulle part sur la facture passe, et le code ne peut pas s’en apercevoir : s’en apercevoir supposerait de retrouver le montant dans la page, c’est-à-dire de refaire le travail que ce niveau devait éviter.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus.
Pour aller plus loin
- Service-Public Entreprendre — Mentions obligatoires sur une facture
- LayoutLMv3 — Pre-training for Document AI with Unified Text and Image Masking
- microsoft/layoutlmv3-base — le point de contrôle qui fermerait le niveau N2 si sa licence le permettait
- Factur-X — la facture hybride PDF et XML, un des trois formats du socle
- impots.gouv.fr — à partir de quand suis-je concerné par la réforme de la facturation électronique ?
- impots.gouv.fr — spécifications externes et normes de la facturation électronique
Source des chiffres
- Service-Public Entreprendre — mention obligatoire de l’indemnité forfaitaire pour frais de recouvrement, réclamable à un client professionnel
- Hugging Face — carte de microsoft/layoutlmv3-base : licence CC BY-NC-SA 4.0
- Transformers — documentation LayoutLMv3 : processeur, mots et boîtes sur la grille 0-1000, 512 positions de jetons
- Transformers.js — liste des architectures prises en charge, sans LayoutLM
- OpenAI — Images and vision : image en URL de données, facturation par dimensions, formats acceptés
- impots.gouv.fr, foire aux questions « Je découvre la facturation électronique », version du 01/09/2026, question 5.1 : « Depuis le 1er septembre 2026, toutes les entreprises, quelles que soient leur taille et leur forme juridique, ont l’obligation de recevoir des factures sous format électronique, par l’intermédiaire d’une plateforme agréée » ; les grandes entreprises et les ETI doivent aussi les émettre, les petites, moyennes et micro-entreprises « au plus tard le 1er septembre 2027 »
- impots.gouv.fr, dossier de spécifications externes de la facturation électronique v3.2 (30/04/2026), section 2 : « le respect du socle minimum de formats reposant sur des standards sémantiques et syntaxiques respectant la norme européenne EN16931 pour faciliter les échanges : UBL, CII et Factur-X »
- Même dossier, annexe 1 « Format sémantique FE e-invoicing », chemins CII lus par l’extrait N0 : `/rsm:CrossIndustryInvoice/rsm:ExchangedDocument/ram:ID` (BT-1), `…/ram:IssueDateTime/udt:DateTimeString` (BT-2), `…/ram:SpecifiedTradeSettlementHeaderMonetarySummation/ram:TaxBasisTotalAmount` (BT-109) et `…/ram:TaxTotalAmount` (BT-110)
- Même dossier, annexe 7 « Règles de gestion » v1.9, règle BR-CO-15 : « Montant total de la facture TVA comprise (BT-112) = Montant total de la facture hors TVA (BT-109) + Montant total de TVA de la facture (BT-110) » ; la même annexe note qu’« en profil extended, la règle BR-CO-15 ne s’applique pas »
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.