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

snippets/extract-fields-from-invoice/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
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

snippets/extract-fields-from-invoice/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * 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

  1. RecommandéN0Lecture du fichier structuré, puis ancrage sur les libellés pour ce qui resteGratuit · Instantané
  2. N1Traits de position et de mise en forme, puis classifieur de lignesnégligeable · Instantané
  3. N2Niveau sans objet ici
  4. 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

snippets/extract-fields-from-invoice/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
É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 fields

JavaScript

snippets/extract-fields-from-invoice/n1.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * É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

snippets/extract-fields-from-invoice/n3.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
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.005

JavaScript

snippets/extract-fields-from-invoice/n3.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * 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

Source des chiffres

Métadonnées