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.
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.
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}');
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:
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.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.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.diesel, gazole, gasoline, petrol, essence, unleaded, E10, E85, or B7. Adapt this list to the countries and products your app supports./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.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.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.
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[eé]|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.
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 B7Volume: 51.25Total 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. |
LITRESL46.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. |
Diesel14. 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 20260907Total 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.
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:
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.
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.
hocrTextLayout bounding boxes, extract one quantity per fuel row, then sum the validated rows.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.
Start with a free trial license to test the SDK, or contact us directly for a custom quote tailored to your needs.
© 2026 The Grizzly Labs. All rights reserved.