How to extract fuel quantities from invoices

Available on: iOS Android React Native Flutter

Fuel invoices generally contain the delivered volume alongside a unit price and a line total. The Genius Scan SDK can capture and clean up the invoice, then run OCR entirely on the device. Your application can extract the number of liters from the recognized text.

The OCR result is unstructured text, so detecting a fuel quantity is application logic. This tutorial shows a practical strategy that works across common invoice layouts and avoids confusing the quantity with a price, tax rate, invoice number, or another measurement.

Before you start

This tutorial assumes that your app already scans and enhances documents. To set up automatic document detection and the standard scanning UI, follow the Document Scanning Quick Start. If you provide your own camera UI, start with the Custom Integration Quick Start and Document Finder.

The code below only adds OCR and post-processes its result. For more information about OCR configurations and supported languages, see the OCR guide and supported OCR languages.

For best results, run OCR on the enhanced image produced after document detection, perspective correction, and filtering. Make sure the user captures the entire invoice, including the fuel description, quantity, unit, unit price, and line total.

Enable OCR in the scan flow

Add an OCR configuration to the ScanFlow configuration that your document scanning flow already uses. Native apps use GSKScanFlowConfiguration or ScanFlowConfiguration; React Native and Flutter use the equivalent configuration map. Request only the languages that occur on your invoices. The examples request raw text because that is the only OCR output needed by the extractor.

let configuration = GSKScanFlowConfiguration()

let ocrConfiguration = GSKScanFlowOCRConfiguration()
ocrConfiguration.languageTags = ["en-US", "fr-FR"]
ocrConfiguration.outputFormats = .rawText
configuration.ocrConfiguration = ocrConfiguration

// Keep the document-scanning options configured by your existing flow.
val configuration = ScanFlowConfiguration().apply {
    ocrConfiguration = ScanFlowConfiguration.OcrConfiguration().apply {
        languages = listOf("en-US", "fr-FR")
        outputFormats = EnumSet.of(
            ScanFlowConfiguration.OcrOutputFormat.RAW_TEXT
        )
    }

    // Keep the document-scanning options configured by your existing flow.
}
const configuration = {
  ocrConfiguration: {
    languages: ['en-US', 'fr-FR'],
    outputFormats: ['rawText']
  }

  // Keep the document-scanning options configured by your existing flow.
};
final configuration = <String, dynamic>{
  'ocrConfiguration': <String, dynamic>{
    'languages': <String>['en-US', 'fr-FR'],
    'outputFormats': <String>['rawText'],
  },

  // Keep the document-scanning options configured by your existing flow.
};

Start the scan flow as described in the Document Scanning Quick Start. When it completes, read the raw OCR text from the first scanned page and pass it to your extractor.

guard
    let scan = result.scans.first,
    let text = scan.ocrResult?.text,
    let fuelQuantity = extractFuelQuantity(from: text)
else {
    // Ask the user to enter or confirm the quantity.
    return
}

print("Detected volume: \(fuelQuantity.liters) L")
print("Confidence: \(fuelQuantity.confidence.rawValue)")
print("Source: \(fuelQuantity.sourceLine)")
val text = result.scans
    ?.firstOrNull()
    ?.ocrResult
    ?.text

val fuelQuantity = text?.let(::extractFuelQuantity)
if (fuelQuantity == null) {
    // Ask the user to enter or confirm the quantity.
    return
}

Log.d("FuelInvoice", "Detected volume: ${fuelQuantity.liters} L")
Log.d("FuelInvoice", "Confidence: ${fuelQuantity.confidence}")
Log.d("FuelInvoice", "Source: ${fuelQuantity.sourceLine}")
import RNGeniusScan from '@thegrizzlylabs/react-native-genius-scan';

const result = await RNGeniusScan.scanWithConfiguration(configuration);
const text = result.scans[0]?.ocrResult?.text;
const fuelQuantity = text ? extractFuelQuantity(text) : null;

if (!fuelQuantity) {
  // Ask the user to enter or confirm the quantity.
  return;
}

console.log(`Detected volume: ${fuelQuantity.liters} L`);
console.log(`Confidence: ${fuelQuantity.confidence}`);
console.log(`Source: ${fuelQuantity.sourceLine}`);
import 'package:flutter_genius_scan/flutter_genius_scan.dart';

final result = await FlutterGeniusScan.scanWithConfiguration(configuration);
final scans = result['scans'];

String? text;
if (scans is List && scans.isNotEmpty) {
  final firstScan = scans.first;
  if (firstScan is Map) {
    final ocrResult = firstScan['ocrResult'];
    if (ocrResult is Map && ocrResult['text'] is String) {
      text = ocrResult['text'] as String;
    }
  }
}

final fuelQuantity = text == null ? null : extractFuelQuantity(text);
if (fuelQuantity == null) {
  // Ask the user to enter or confirm the quantity.
  return;
}

print('Detected volume: ${fuelQuantity.liters} L');
print('Confidence: ${fuelQuantity.confidence.name}');
print('Source: ${fuelQuantity.sourceLine}');

Rank candidates instead of relying on one regular expression

A fuel invoice may contain lines such as:

Product       Quantity     Unit price       Amount
Diesel B7      42,310 L       1,729 €/L      73,15 EUR

All four numbers are valid decimal values, but only 42,310 is the quantity. Treat every possible match as a candidate and give it a score based on the evidence around it.

Apply the heuristics in this order:

  1. Prefer a value next to an unambiguous liter unit. Accept L, LT, LTR, LIT, LTS, liter(s), and litre(s), case-insensitively. The common form is a number followed by its unit, but OCR can return a table cell as L 46.390, so accept that anchored unit-before-value form too. Allow harmless OCR separator characters, so both 42,310 L and 2.17_litre match, but do not allow / or currency symbols between the number and unit. A direct unit match is the strongest signal.
  2. Accept labeled values. Patterns such as Volume: 42,310, Quantity 42.310, or Litres 42.310 are useful when OCR puts the table header and value on the same line but drops the unit.
  3. Handle labels and values split across OCR lines. Table layouts can produce LITRES, L, and 46.390 as three separate lines. When a line contains only a liter label, inspect at most the next two non-empty lines for a standalone number, optionally preceded by L. Keep this search window small so the label is not paired with a later price or total.
  4. Use nearby fuel words to raise confidence. Look on the same line and the lines immediately above and below for product names such as diesel, gazole, gasoline, petrol, essence, unleaded, E10, E85, or B7. Adapt this list to the countries and products your app supports.
  5. Reject unit prices. A value associated with /L, per L, price/L, or a currency symbol is a price, not a volume. Do not remove the slash and then run the quantity pattern.
  6. Check a configurable plausible range. The example accepts 0.1...2,000 liters as a broad range. A passenger-vehicle workflow should use a much smaller upper bound; a commercial fuel-delivery workflow may need a larger one.
  7. Keep the source line and a confidence level. This lets you show the evidence to the user instead of silently accepting a weak value.

OCR sometimes inserts whitespace after a decimal mark, for example 14. 73L instead of 14.73L. Accept whitespace only between a decimal mark and its following digits, then remove it while parsing. If you instead start a second match at 73L, you silently turn 14.73 L into 73 L.

Do not globally replace OCR-confusable characters. For example, OCR may read an uppercase L as I or 1, but changing every I or 1 into L corrupts invoice numbers and amounts. Only repair a suspected unit when a quantity label and fuel product provide independent evidence, and mark the result as low confidence.

Example extractor

The following implementation recognizes explicit liter units and labeled quantities. It uses adjacent lines for fuel context and returns the highest-scoring plausible candidate.

import Foundation

struct FuelQuantity {
    enum Confidence: String {
        case low, medium, high
    }

    let liters: Double
    let sourceLine: String
    let confidence: Confidence
}

private struct FuelCandidate {
    let liters: Double
    let sourceLine: String
    let score: Int
}

private let fuelWords = [
    "diesel", "gazole", "gasoline", "petrol", "essence",
    "unleaded", "e10", "e85", "b7"
]

private let fuelQuantityRulePatterns: [(String, Int)] = [
    // A number directly followed by a liter unit: "42,310 L".
    (#"(?i)(?<![\p{L}\d])(\d{1,4}(?:[.,]\s*\d{1,3})?)[\s_:=-]*(?:l|lt|ltr|lit|lts|litres?|liters?)\b"#, 6),
    // An anchored unit followed by a number: "L 46.390".
    (#"(?i)^\s*(?:l|lt|ltr|lit|lts|litres?|liters?)[\s_:=-]+(\d{1,4}(?:[.,]\s*\d{1,3})?)\s*$"#, 6),
    // A quantity label followed by a number: "Volume: 42,310".
    (#"(?i)\b(?:volume|quantity|qty|quantit[eé]|menge)\b[^\d]{0,12}(\d{1,4}(?:[.,]\s*\d{1,3})?)"#, 5),
    // A line-leading unit label followed by a number: "Litres: 42,310".
    (#"(?i)^\s*(?:lit|lts|litres?|liters?)\s*:?\s*(\d{1,4}(?:[.,]\s*\d{1,3})?)"#, 5)
]

private let fuelQuantityRules: [(NSRegularExpression, Int)] =
    fuelQuantityRulePatterns.compactMap { pattern, score in
        guard let expression = try? NSRegularExpression(pattern: pattern) else {
            return nil
        }
        return (expression, score)
    }

private let standaloneLiterLabelExpression = try? NSRegularExpression(
    pattern: #"(?i)^\s*(?:lit|lts|litres?|liters?)\s*$"#
)

private let standaloneLiterValueExpression = try? NSRegularExpression(
    pattern: #"(?i)^\s*(?:l\s*)?(\d{1,4}(?:[.,]\s*\d{1,3})?)(?:\s*(?:l|lt|ltr|lit|lts|litres?|liters?))?\s*$"#
)

private let standaloneLiterUnitExpression = try? NSRegularExpression(
    pattern: #"(?i)^\s*l\s*$"#
)

func extractFuelQuantity(from text: String) -> FuelQuantity? {
    let lines = text
        .replacingOccurrences(of: "\u{00a0}", with: " ")
        .components(separatedBy: .newlines)
        .map { $0.trimmingCharacters(in: .whitespaces) }
        .filter { !$0.isEmpty }

    var candidates = [FuelCandidate]()

    for (lineIndex, line) in lines.enumerated() {
        let start = max(0, lineIndex - 1)
        let end = min(lines.count - 1, lineIndex + 1)
        let context = lines[start...end].joined(separator: " ").lowercased()
        let hasFuelContext = fuelWords.contains { context.contains($0) }
        let searchRange = NSRange(line.startIndex..<line.endIndex, in: line)

        for (expression, baseScore) in fuelQuantityRules {
            for match in expression.matches(in: line, range: searchRange) {
                guard
                    let tokenRange = Range(match.range(at: 1), in: line),
                    let liters = parseLocalizedDecimal(String(line[tokenRange])),
                    (0.1...2_000).contains(liters)
                else {
                    continue
                }

                candidates.append(FuelCandidate(
                    liters: liters,
                    sourceLine: line,
                    score: baseScore + (hasFuelContext ? 2 : 0)
                ))
            }
        }
    }

    // OCR can split a table row into "LITRES", "L", and "46.390".
    if let labelExpression = standaloneLiterLabelExpression,
       let valueExpression = standaloneLiterValueExpression,
       let unitExpression = standaloneLiterUnitExpression {
        for (labelIndex, line) in lines.enumerated() {
            let lineRange = NSRange(line.startIndex..<line.endIndex, in: line)
            guard labelExpression.firstMatch(in: line, range: lineRange) != nil else {
                continue
            }

            var valueIndex = labelIndex + 1
            guard valueIndex < lines.count else {
                continue
            }

            let nextLine = lines[valueIndex]
            let nextLineRange = NSRange(
                nextLine.startIndex..<nextLine.endIndex,
                in: nextLine
            )
            if unitExpression.firstMatch(in: nextLine, range: nextLineRange) != nil {
                valueIndex += 1
            }

            guard valueIndex < lines.count else {
                continue
            }

            let valueLine = lines[valueIndex]
            let valueLineRange = NSRange(
                valueLine.startIndex..<valueLine.endIndex,
                in: valueLine
            )
            guard
                let match = valueExpression.firstMatch(
                    in: valueLine,
                    range: valueLineRange
                ),
                let tokenRange = Range(match.range(at: 1), in: valueLine),
                let liters = parseLocalizedDecimal(String(valueLine[tokenRange])),
                (0.1...2_000).contains(liters)
            else {
                continue
            }

            candidates.append(FuelCandidate(
                liters: liters,
                sourceLine: lines[labelIndex...valueIndex].joined(separator: " / "),
                score: 7
            ))
        }
    }

    guard
        let best = candidates.max(by: { $0.score < $1.score }),
        best.score >= 5
    else {
        return nil
    }

    let confidence: FuelQuantity.Confidence = switch best.score {
    case 8...: .high
    case 6...: .medium
    default: .low
    }

    return FuelQuantity(
        liters: best.liters,
        sourceLine: best.sourceLine,
        confidence: confidence
    )
}

private func parseLocalizedDecimal(_ token: String) -> Double? {
    var normalized = token.replacingOccurrences(of: " ", with: "")

    if let comma = normalized.lastIndex(of: ","),
       let dot = normalized.lastIndex(of: ".") {
        // The rightmost separator is assumed to be the decimal separator.
        if comma > dot {
            normalized = normalized.replacingOccurrences(of: ".", with: "")
            normalized = normalized.replacingOccurrences(of: ",", with: ".")
        } else {
            normalized = normalized.replacingOccurrences(of: ",", with: "")
        }
    } else {
        normalized = normalized.replacingOccurrences(of: ",", with: ".")
    }

    return Double(normalized)
}
import java.util.Locale

data class FuelQuantity(
    val liters: Double,
    val sourceLine: String,
    val confidence: Confidence
) {
    enum class Confidence { LOW, MEDIUM, HIGH }
}

private data class FuelCandidate(
    val liters: Double,
    val sourceLine: String,
    val score: Int
)

private val fuelWords = listOf(
    "diesel", "gazole", "gasoline", "petrol", "essence",
    "unleaded", "e10", "e85", "b7"
)

private val fuelQuantityRules = listOf(
    // A number directly followed by a liter unit: "42,310 L".
    Regex(
        """(?i)(?<![\p{L}\d])(\d{1,4}(?:[.,]\s*\d{1,3})?)[\s_:=-]*(?:l|lt|ltr|lit|lts|litres?|liters?)\b"""
    ) to 6,
    // An anchored unit followed by a number: "L 46.390".
    Regex(
        """(?i)^\s*(?:l|lt|ltr|lit|lts|litres?|liters?)[\s_:=-]+(\d{1,4}(?:[.,]\s*\d{1,3})?)\s*$"""
    ) to 6,
    // A quantity label followed by a number: "Volume: 42,310".
    Regex(
        """(?i)\b(?:volume|quantity|qty|quantit[eé]|menge)\b[^\d]{0,12}(\d{1,4}(?:[.,]\s*\d{1,3})?)"""
    ) to 5,
    // A line-leading unit label followed by a number: "Litres: 42,310".
    Regex(
        """(?i)^\s*(?:lit|lts|litres?|liters?)\s*:?\s*(\d{1,4}(?:[.,]\s*\d{1,3})?)"""
    ) to 5
)

private val standaloneLiterLabel = Regex(
    """(?i)^\s*(?:lit|lts|litres?|liters?)\s*$"""
)

private val standaloneLiterValue = Regex(
    """(?i)^\s*(?:l\s*)?(\d{1,4}(?:[.,]\s*\d{1,3})?)(?:\s*(?:l|lt|ltr|lit|lts|litres?|liters?))?\s*$"""
)

private val standaloneLiterUnit = Regex("""(?i)^\s*l\s*$""")

fun extractFuelQuantity(text: String): FuelQuantity? {
    val lines = text
        .replace('\u00a0', ' ')
        .lines()
        .map(String::trim)
        .filter(String::isNotEmpty)

    val candidates = buildList {
        lines.forEachIndexed { lineIndex, line ->
            val context = lines.subList(
                maxOf(0, lineIndex - 1),
                minOf(lines.size, lineIndex + 2)
            ).joinToString(" ").lowercase(Locale.ROOT)
            val hasFuelContext = fuelWords.any(context::contains)

            fuelQuantityRules.forEach { (expression, baseScore) ->
                expression.findAll(line).forEach { match ->
                    val liters = parseLocalizedDecimal(match.groupValues[1])
                    if (liters != null && liters in 0.1..2_000.0) {
                        add(FuelCandidate(
                            liters = liters,
                            sourceLine = line,
                            score = baseScore + if (hasFuelContext) 2 else 0
                        ))
                    }
                }
            }
        }

        // OCR can split a table row into "LITRES", "L", and "46.390".
        lines.forEachIndexed { labelIndex, line ->
            if (!standaloneLiterLabel.matches(line)) {
                return@forEachIndexed
            }

            var valueIndex = labelIndex + 1
            if (valueIndex > lines.lastIndex) {
                return@forEachIndexed
            }

            if (standaloneLiterUnit.matches(lines[valueIndex])) {
                valueIndex += 1
            }
            if (valueIndex > lines.lastIndex) {
                return@forEachIndexed
            }

            val match = standaloneLiterValue.matchEntire(lines[valueIndex])
                ?: return@forEachIndexed
            val liters = parseLocalizedDecimal(match.groupValues[1])
                ?: return@forEachIndexed
            if (liters !in 0.1..2_000.0) {
                return@forEachIndexed
            }

            add(FuelCandidate(
                liters = liters,
                sourceLine = lines
                    .subList(labelIndex, valueIndex + 1)
                    .joinToString(" / "),
                score = 7
            ))
        }
    }

    val best = candidates.maxByOrNull(FuelCandidate::score)
        ?.takeIf { it.score >= 5 }
        ?: return null

    val confidence = when {
        best.score >= 8 -> FuelQuantity.Confidence.HIGH
        best.score >= 6 -> FuelQuantity.Confidence.MEDIUM
        else -> FuelQuantity.Confidence.LOW
    }

    return FuelQuantity(best.liters, best.sourceLine, confidence)
}

private fun parseLocalizedDecimal(token: String): Double? {
    var normalized = token.replace(" ", "")
    val comma = normalized.lastIndexOf(',')
    val dot = normalized.lastIndexOf('.')

    normalized = when {
        comma >= 0 && dot >= 0 && comma > dot -> normalized
            .replace(".", "")
            .replace(',', '.')
        comma >= 0 && dot >= 0 -> normalized.replace(",", "")
        else -> normalized.replace(',', '.')
    }

    return normalized.toDoubleOrNull()
}
const fuelWords = [
  'diesel', 'gazole', 'gasoline', 'petrol', 'essence',
  'unleaded', 'e10', 'e85', 'b7'
];

const fuelQuantityRules = [
  {
    // A number directly followed by a liter unit: "42,310 L".
    expression: /(?:^|[^A-Za-z0-9])(\d{1,4}(?:[.,]\s*\d{1,3})?)[\s_:=-]*(?:l|lt|ltr|lit|lts|litres?|liters?)\b/gi,
    score: 6
  },
  {
    // An anchored unit followed by a number: "L 46.390".
    expression: /^\s*(?:l|lt|ltr|lit|lts|litres?|liters?)[\s_:=-]+(\d{1,4}(?:[.,]\s*\d{1,3})?)\s*$/gi,
    score: 6
  },
  {
    // A quantity label followed by a number: "Volume: 42,310".
    expression: /\b(?:volume|quantity|qty|quantit[]|menge)\b[^\d]{0,12}(\d{1,4}(?:[.,]\s*\d{1,3})?)/gi,
    score: 5
  },
  {
    // A line-leading unit label followed by a number: "Litres: 42,310".
    expression: /^\s*(?:lit|lts|litres?|liters?)\s*:?\s*(\d{1,4}(?:[.,]\s*\d{1,3})?)/gi,
    score: 5
  }
];

const standaloneLiterLabel = /^\s*(?:lit|lts|litres?|liters?)\s*$/i;
const standaloneLiterValue = /^\s*(?:l\s*)?(\d{1,4}(?:[.,]\s*\d{1,3})?)(?:\s*(?:l|lt|ltr|lit|lts|litres?|liters?))?\s*$/i;
const standaloneLiterUnit = /^\s*l\s*$/i;

function extractFuelQuantity(text) {
  const lines = text
    .replace(/\u00a0/g, ' ')
    .split(/\r?\n/)
    .map(line => line.trim())
    .filter(line => line.length > 0);

  const candidates = [];

  lines.forEach((line, lineIndex) => {
    const context = lines
      .slice(Math.max(0, lineIndex - 1), Math.min(lines.length, lineIndex + 2))
      .join(' ')
      .toLowerCase();
    const hasFuelContext = fuelWords.some(word => context.includes(word));

    fuelQuantityRules.forEach(({ expression, score }) => {
      expression.lastIndex = 0;
      let match;
      while ((match = expression.exec(line)) !== null) {
        const liters = parseLocalizedDecimal(match[1]);
        if (liters === null || liters < 0.1 || liters > 2000) {
          continue;
        }

        candidates.push({
          liters,
          sourceLine: line,
          score: score + (hasFuelContext ? 2 : 0)
        });
      }
    });
  });

  // OCR can split a table row into "LITRES", "L", and "46.390".
  lines.forEach((line, labelIndex) => {
    if (!standaloneLiterLabel.test(line)) {
      return;
    }

    let valueIndex = labelIndex + 1;
    if (valueIndex >= lines.length) {
      return;
    }

    if (standaloneLiterUnit.test(lines[valueIndex])) {
      valueIndex += 1;
    }
    if (valueIndex >= lines.length) {
      return;
    }

    const match = standaloneLiterValue.exec(lines[valueIndex]);
    const liters = match ? parseLocalizedDecimal(match[1]) : null;
    if (liters === null || liters < 0.1 || liters > 2000) {
      return;
    }

    candidates.push({
      liters,
      sourceLine: lines.slice(labelIndex, valueIndex + 1).join(' / '),
      score: 7
    });
  });

  const best = candidates.reduce(
    (current, candidate) =>
      current === null || candidate.score > current.score ? candidate : current,
    null
  );
  if (best === null || best.score < 5) {
    return null;
  }

  const confidence = best.score >= 8
    ? 'high'
    : best.score >= 6
      ? 'medium'
      : 'low';

  return {
    liters: best.liters,
    sourceLine: best.sourceLine,
    confidence
  };
}

function parseLocalizedDecimal(token) {
  let normalized = token.replace(/\s/g, '');
  const comma = normalized.lastIndexOf(',');
  const dot = normalized.lastIndexOf('.');

  if (comma >= 0 && dot >= 0) {
    if (comma > dot) {
      normalized = normalized.replace(/\./g, '').replace(',', '.');
    } else {
      normalized = normalized.replace(/,/g, '');
    }
  } else {
    normalized = normalized.replace(',', '.');
  }

  const value = Number(normalized);
  return Number.isFinite(value) ? value : null;
}
enum FuelQuantityConfidence { low, medium, high }

class FuelQuantity {
  const FuelQuantity({
    required this.liters,
    required this.sourceLine,
    required this.confidence,
  });

  final double liters;
  final String sourceLine;
  final FuelQuantityConfidence confidence;
}

class _FuelCandidate {
  const _FuelCandidate({
    required this.liters,
    required this.sourceLine,
    required this.score,
  });

  final double liters;
  final String sourceLine;
  final int score;
}

class _FuelQuantityRule {
  _FuelQuantityRule(String pattern, this.score)
      : expression = RegExp(pattern, caseSensitive: false);

  final RegExp expression;
  final int score;
}

const fuelWords = <String>[
  'diesel', 'gazole', 'gasoline', 'petrol', 'essence',
  'unleaded', 'e10', 'e85', 'b7',
];

final fuelQuantityRules = <_FuelQuantityRule>[
  // A number directly followed by a liter unit: "42,310 L".
  _FuelQuantityRule(
    r'(?:^|[^A-Za-z0-9])(\d{1,4}(?:[.,]\s*\d{1,3})?)[\s_:=-]*(?:l|lt|ltr|lit|lts|litres?|liters?)\b',
    6,
  ),
  // An anchored unit followed by a number: "L 46.390".
  _FuelQuantityRule(
    r'^\s*(?:l|lt|ltr|lit|lts|litres?|liters?)[\s_:=-]+(\d{1,4}(?:[.,]\s*\d{1,3})?)\s*$',
    6,
  ),
  // A quantity label followed by a number: "Volume: 42,310".
  _FuelQuantityRule(
    r'\b(?:volume|quantity|qty|quantit[eé]|menge)\b[^\d]{0,12}(\d{1,4}(?:[.,]\s*\d{1,3})?)',
    5,
  ),
  // A line-leading unit label followed by a number: "Litres: 42,310".
  _FuelQuantityRule(
    r'^\s*(?:lit|lts|litres?|liters?)\s*:?\s*(\d{1,4}(?:[.,]\s*\d{1,3})?)',
    5,
  ),
];

final standaloneLiterLabel = RegExp(
  r'^\s*(?:lit|lts|litres?|liters?)\s*$',
  caseSensitive: false,
);

final standaloneLiterValue = RegExp(
  r'^\s*(?:l\s*)?(\d{1,4}(?:[.,]\s*\d{1,3})?)(?:\s*(?:l|lt|ltr|lit|lts|litres?|liters?))?\s*$',
  caseSensitive: false,
);

final standaloneLiterUnit = RegExp(
  r'^\s*l\s*$',
  caseSensitive: false,
);

FuelQuantity? extractFuelQuantity(String text) {
  final lines = text
      .replaceAll('\u00a0', ' ')
      .split(RegExp(r'\r?\n'))
      .map((line) => line.trim())
      .where((line) => line.isNotEmpty)
      .toList();

  final candidates = <_FuelCandidate>[];

  for (var lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
    final line = lines[lineIndex];
    final context = lines
        .sublist(
          lineIndex > 0 ? lineIndex - 1 : 0,
          lineIndex + 2 < lines.length ? lineIndex + 2 : lines.length,
        )
        .join(' ')
        .toLowerCase();
    final hasFuelContext = fuelWords.any(context.contains);

    for (final rule in fuelQuantityRules) {
      for (final match in rule.expression.allMatches(line)) {
        final token = match.group(1);
        final liters = token == null ? null : parseLocalizedDecimal(token);
        if (liters == null || liters < 0.1 || liters > 2000) {
          continue;
        }

        candidates.add(_FuelCandidate(
          liters: liters,
          sourceLine: line,
          score: rule.score + (hasFuelContext ? 2 : 0),
        ));
      }
    }
  }

  // OCR can split a table row into "LITRES", "L", and "46.390".
  for (var labelIndex = 0; labelIndex < lines.length; labelIndex += 1) {
    if (!standaloneLiterLabel.hasMatch(lines[labelIndex])) {
      continue;
    }

    var valueIndex = labelIndex + 1;
    if (valueIndex >= lines.length) {
      continue;
    }

    if (standaloneLiterUnit.hasMatch(lines[valueIndex])) {
      valueIndex += 1;
    }
    if (valueIndex >= lines.length) {
      continue;
    }

    final match = standaloneLiterValue.firstMatch(lines[valueIndex]);
    final token = match?.group(1);
    final liters = token == null ? null : parseLocalizedDecimal(token);
    if (liters == null || liters < 0.1 || liters > 2000) {
      continue;
    }

    candidates.add(_FuelCandidate(
      liters: liters,
      sourceLine: lines.sublist(labelIndex, valueIndex + 1).join(' / '),
      score: 7,
    ));
  }

  _FuelCandidate? best;
  for (final candidate in candidates) {
    if (best == null || candidate.score > best.score) {
      best = candidate;
    }
  }
  if (best == null || best.score < 5) {
    return null;
  }

  final confidence = best.score >= 8
      ? FuelQuantityConfidence.high
      : best.score >= 6
          ? FuelQuantityConfidence.medium
          : FuelQuantityConfidence.low;

  return FuelQuantity(
    liters: best.liters,
    sourceLine: best.sourceLine,
    confidence: confidence,
  );
}

double? parseLocalizedDecimal(String token) {
  var normalized = token.replaceAll(RegExp(r'\s'), '');
  final comma = normalized.lastIndexOf(',');
  final dot = normalized.lastIndexOf('.');

  if (comma >= 0 && dot >= 0) {
    if (comma > dot) {
      normalized = normalized.replaceAll('.', '').replaceFirst(',', '.');
    } else {
      normalized = normalized.replaceAll(',', '');
    }
  } else {
    normalized = normalized.replaceFirst(',', '.');
  }

  return double.tryParse(normalized);
}

Treat the product keywords, supported labels, plausible range, and decimal conventions as configuration rather than universal constants. The example treats a single comma or period as a decimal separator, which is common for pump quantities such as 42,310 L. If you process bulk-delivery invoices where 2,000 L can mean two thousand liters, parse numbers using the known invoice locale and confirm the interpretation with the line total. Add terms from real invoices received by your users, and test each change against both positive and negative examples.

Try the extractor on sample OCR

Running the extractor above on representative OCR lines produces these results:

OCR text Result Confidence Explanation
Diesel B7 42,310 L 1,729 €/L 42.31 L High The number has an explicit liter unit and fuel context. The unit price is not mistaken for a quantity.
Diesel B7
Volume: 51.25
Total 89.67 EUR
51.25 L Medium The quantity label is accepted and the adjacent line supplies fuel context.
Litres: 36.800 36.8 L Low The line-leading unit label is useful, but there is no nearby fuel product to corroborate it.
LITRES
L
46.390
46.39 L Medium The bounded multi-line rule reconnects a label, standalone unit, and value split by OCR.
2.17_litre Pump # 03 2.17 L Medium A harmless underscore inserted by OCR is tolerated between the value and unit.
Diesel
14. 73L
14.73 L High Whitespace inserted after the decimal point is removed while parsing the complete number.
Price per liter 1.729 No result A unit price does not match the explicit quantity or line-leading label patterns.
Diesel B7 5000 L No result The value is outside the example’s configured maximum of 2,000 liters.
Invoice 20260907
Total 73.15 EUR
No result Unrelated invoice numbers and currency amounts are ignored.

These confidence levels are deliberately conservative. In a production app, show medium- and low-confidence values for confirmation, and also ask for confirmation when two different candidates have similar scores.

The extractor cannot recover information that OCR did not recognize reliably. For example, if 40,42 L is recognized as 44,42 A, both the value and unit are uncertain. Returning no result is safer than changing A to L and accepting an incorrect quantity. Improve capture and OCR first, or ask the user to enter the value.

Cross-check the quantity with prices

When the invoice exposes a fuel line’s unit price and line total, verify the chosen quantity with:

expected line total = liters × price per liter

Allow a small tolerance for rounding, for example the greater of 0.05 in the invoice currency or 1% of the line total. Compare values on the same tax basis: a pre-tax unit price cannot be validated directly against a tax-inclusive total.

This check is useful in two ways:

  • Raise the confidence when the detected liters reproduce the fuel line total.
  • As a fallback, calculate line total ÷ unit price when no quantity was recognized, but only when both values clearly belong to the same fuel line.

Do not divide the invoice grand total by the first /L price unless the invoice contains only fuel and no fees, discounts, other products, or mixed VAT rates. On a multi-line invoice, use OCR layout information (hocrTextLayout) to associate values by row rather than relying only on raw text order.

Handle ambiguous and multi-line invoices

Some documents repeat the quantity in a detail row and a summary. Others contain several refueling events. Do not sum every regex match: that can count the same fuel twice.

  • For a single-fill receipt or invoice, choose the best-supported candidate and ask the user to confirm medium- or low-confidence results.
  • For an invoice with multiple fuel line items, also request hOCR output, identify table rows using the hocrTextLayout bounding boxes, extract one quantity per fuel row, then sum the validated rows.
  • If the document explicitly uses gallons, keep the detected unit with the value. Convert it to liters only as a separate, explicit step using the appropriate gallon definition.
  • If two candidates have similar scores but different values, return an ambiguous result and require confirmation instead of guessing.

Always let users review the extracted quantity before it affects billing, reimbursement, tax reporting, or fleet analytics. Store the original OCR text or source line alongside the confirmed value so extraction errors can be diagnosed and your heuristics can be improved safely.

Ready to get started?

Start with a free trial license to test the SDK, or contact us directly for a custom quote tailored to your needs.

Products

Industries

Case Studies

Integration

Company

© 2026 The Grizzly Labs. All rights reserved.