From 3460633f17e7d3d2571a497332188d3a11d9fe58 Mon Sep 17 00:00:00 2001 From: hangy Date: Sun, 12 Oct 2025 13:28:54 +0200 Subject: [PATCH 1/2] feat: Add GS1 Digital Link parsing support --- example/main.dart | 16 +++++++ lib/src/barcode_parser.dart | 87 ++++++++++++++++++++++++++++++++++- test/barcode_parser_test.dart | 51 ++++++++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) diff --git a/example/main.dart b/example/main.dart index a4510ad..789aea1 100644 --- a/example/main.dart +++ b/example/main.dart @@ -3,8 +3,24 @@ import 'package:gs1_barcode_parser/gs1_barcode_parser.dart'; main() { final String barcode = ']C101040123456789011715012910ABC1233932971471131030005253922471142127649716'; + // GS1 Digital Link official examples + final digitalLink1 = 'https://id.gs1.org/01/09506000134352/21/12345'; + final digitalLink2 = + 'https://id.gs1.org/01/09506000134376/10/ABC/21/12345?17=231231'; + final digitalLink3 = + 'https://example.com/some/path/01/12345678901231/21/ABC?10=LOT123'; final parser = GS1BarcodeParser.defaultParser(); + final result = parser.parse(barcode); print(result); + + print('GS1 Digital Link Example 1:'); + print(parser.parse(digitalLink1)); + + print('\nGS1 Digital Link Example 2:'); + print(parser.parse(digitalLink2)); + + print('\nGS1 Digital Link Example 3:'); + print(parser.parse(digitalLink3)); } diff --git a/lib/src/barcode_parser.dart b/lib/src/barcode_parser.dart index d31214e..07921cd 100644 --- a/lib/src/barcode_parser.dart +++ b/lib/src/barcode_parser.dart @@ -29,6 +29,8 @@ class GS1BarcodeParser { final GS1BarcodeParserConfig _config; + static final _aiPattern = RegExp(r'^\d{2,4}$'); + GS1BarcodeParser._({ required GS1BarcodeParserConfig config, required GS1CodeParser codeParser, @@ -68,7 +70,12 @@ class GS1BarcodeParser { /// Parse barcode string GS1Barcode parse(String data, {CodeType? codeType}) { if (data.isEmpty) { - GS1DataException(message: 'Barcode is empty'); + throw GS1DataException(message: 'Barcode is empty'); + } + + // Detect GS1 Digital Link URL (starts with http/https) + if (data.startsWith('http://') || data.startsWith('https://')) { + return _parseDigitalLink(data, codeType); } final codeWithRest = _codeParser( @@ -103,7 +110,7 @@ class GS1BarcodeParser { AI? _getAI(String ai, [Map customAIs = const {}]) => customAIs[ai] ?? AI.AIS[ai]; - /// Get ans parse AI + /// Get and parse AI ParsedElementWithRest _identifyAI(String data, [Map customAIs = const {}]) { if (data.isEmpty) { @@ -156,6 +163,82 @@ class GS1BarcodeParser { return fnc1 + result; } } + + /// Internal: Parse GS1 Digital Link standard URL + /// Example: https://id.gs1.org/01/09506000134352/21/12345 + GS1Barcode _parseDigitalLink(String url, CodeType? codeType) { + final uri = Uri.tryParse(url); + if (uri == null || + (uri.pathSegments.isEmpty && uri.queryParameters.isEmpty)) { + throw GS1ParseException(message: 'Invalid GS1 Digital Link URL'); + } + + // Extract AI/value pairs directly without building intermediate string + final aiPairs = {}; + // Find all AI/value pairs in the path + final segments = uri.pathSegments; + int i = 0; + while (i < segments.length - 1) { + final ai = segments[i]; + // GS1 AIs are typically 2-4 digits + if (_aiPattern.hasMatch(ai)) { + final value = segments[i + 1]; + aiPairs[ai] = value; + i += 2; + } else { + i += 1; + } + } + + // Also extract AI/value pairs from query parameters + uri.queryParameters.forEach((key, value) { + if (_aiPattern.hasMatch(key) && value.isNotEmpty) { + aiPairs[key] = value; + } + }); + + if (aiPairs.isEmpty) { + throw GS1ParseException(message: 'No GS1 data found in Digital Link URL'); + } + + // Parse AI pairs directly into elements + return _parseAIPairs(aiPairs, codeType); + } + + /// Internal: Parse AI key-value pairs directly into GS1Barcode + GS1Barcode _parseAIPairs(Map aiPairs, CodeType? codeType) { + final elements = {}; + + // Process each AI/value pair directly + aiPairs.forEach((aiCode, value) { + final ai = _getAI(aiCode, _config.customAIs); + if (ai == null) { + throw GS1ParseException(message: 'AI not found for $aiCode'); + } + + final parser = _elementParsers[ai.type]; + if (parser == null) { + throw GS1ParseException( + message: 'Parser not found for AI $aiCode [type:${ai.type}]'); + } + // Create barcode data string for this specific AI to use existing parsers + final aiData = '$aiCode$value'; + final res = parser(aiData, ai, _config); + elements.putIfAbsent(res.element.aiCode, () => res.element); + }); + + // Use provided codeType but always use "GS1 Digital Link" title + final code = Code( + type: codeType ?? CodeType.UNDEFINED, + codeTitle: 'GS1 Digital Link', + fnc1: codeType != null ? (Code.CODES[codeType]?.fnc1 ?? '') : '', + ); + + return GS1Barcode( + code: code, + elements: elements, + ); + } } class GS1ParsedElement { diff --git a/test/barcode_parser_test.dart b/test/barcode_parser_test.dart index 552d21d..046a8af 100644 --- a/test/barcode_parser_test.dart +++ b/test/barcode_parser_test.dart @@ -278,4 +278,55 @@ main() { 'Test1234Test5678Test1234Test5678Test1234Test'); }); }); + + group('GS1 Digital Link parsing', () { + final parser = GS1BarcodeParser.defaultParser(); + + test('GS1 Digital Link with path AIs only', () { + // Official sample: https://id.gs1.org/01/09506000134352/21/12345 + final url = 'https://id.gs1.org/01/09506000134352/21/12345'; + final result = parser.parse(url); + expect(result.hasAI('01'), true); + expect(result.getAIRawData('01'), '09506000134352'); + expect(result.hasAI('21'), true); + expect(result.getAIRawData('21'), '12345'); + }); + + test('GS1 Digital Link with path and query AIs', () { + // Official sample: https://id.gs1.org/01/09506000134376/10/ABC/21/12345?17=231231 + final url = + 'https://id.gs1.org/01/09506000134376/10/ABC/21/12345?17=231231'; + final result = parser.parse(url); + expect(result.hasAI('01'), true); + expect(result.getAIRawData('01'), '09506000134376'); + expect(result.hasAI('10'), true); + expect(result.getAIRawData('10'), 'ABC'); + expect(result.hasAI('21'), true); + expect(result.getAIRawData('21'), '12345'); + expect(result.hasAI('17'), true); + expect(result.getAIRawData('17'), '231231'); + }); + + test('GS1 Digital Link with sub-path and query', () { + // Official sample: https://example.com/some/path/01/12345678901231/21/ABC?10=LOT123 + final url = + 'https://example.com/some/path/01/12345678901231/21/ABC?10=LOT123'; + final result = parser.parse(url); + expect(result.hasAI('01'), true); + expect(result.getAIRawData('01'), '12345678901231'); + expect(result.hasAI('21'), true); + expect(result.getAIRawData('21'), 'ABC'); + expect(result.hasAI('10'), true); + expect(result.getAIRawData('10'), 'LOT123'); + }); + + test('GS1 Digital Link with explicit codeType', () { + final url = 'https://id.gs1.org/01/09506000134352/21/12345'; + final result = parser.parse(url, codeType: CodeType.QR_CODE); + expect(result.code.type, CodeType.QR_CODE); + expect(result.code.codeTitle, 'GS1 Digital Link'); + expect(result.hasAI('01'), true); + expect(result.getAIRawData('01'), '09506000134352'); + }); + }); } From 0c68ab0632bb1a268333292c030e5cf23bb72ee7 Mon Sep 17 00:00:00 2001 From: hangy Date: Sun, 12 Oct 2025 14:49:52 +0200 Subject: [PATCH 2/2] refactor: Simplify element parsing by introducing parseFromParts method --- lib/src/barcode_parser.dart | 7 +- lib/src/element_parser.dart | 191 ++++++++++++++++++++++++++---------- 2 files changed, 141 insertions(+), 57 deletions(-) diff --git a/lib/src/barcode_parser.dart b/lib/src/barcode_parser.dart index 07921cd..9e4bbf8 100644 --- a/lib/src/barcode_parser.dart +++ b/lib/src/barcode_parser.dart @@ -221,10 +221,9 @@ class GS1BarcodeParser { throw GS1ParseException( message: 'Parser not found for AI $aiCode [type:${ai.type}]'); } - // Create barcode data string for this specific AI to use existing parsers - final aiData = '$aiCode$value'; - final res = parser(aiData, ai, _config); - elements.putIfAbsent(res.element.aiCode, () => res.element); + + final element = parser.parseFromParts(aiCode, value, ai, _config); + elements.putIfAbsent(element.aiCode, () => element); }); // Use provided codeType but always use "GS1 Digital Link" title diff --git a/lib/src/element_parser.dart b/lib/src/element_parser.dart index 4925658..ad16ea2 100644 --- a/lib/src/element_parser.dart +++ b/lib/src/element_parser.dart @@ -18,6 +18,9 @@ class ParsedElementWithRest { abstract class GS1ElementParser { ParsedElementWithRest call(String data, AI ai, GS1BarcodeParserConfig config); + GS1ParsedElement parseFromParts( + String aiCode, String value, AI ai, GS1BarcodeParserConfig config); + bool verify(String elementData, AI ai) { return ai.regExp.hasMatch(elementData); } @@ -51,20 +54,36 @@ class GS1DateParser extends GS1ElementParser { } final elementDate = elementStr.substring(ai.code.length); - var year = _year2ToYear4(int.parse(elementDate.substring(0, 2), radix: 10)); - final month = int.parse(elementDate.substring(2, 4), radix: 10); - final day = int.parse(elementDate.substring(4), radix: 10); - - final element = GS1ParsedElement( - rawData: elementDate, - aiCode: ai.code, - data: DateTime(year, month, day), - ); + final element = parseFromParts(ai.code, elementDate, ai, config); final rest = getRest(data, offset, config); return ParsedElementWithRest(element: element, rest: rest); } + @override + GS1ParsedElement parseFromParts( + String aiCode, String value, AI ai, GS1BarcodeParserConfig config) { + if (value.length != ai.fixLength) { + throw GS1ParseException( + message: + 'Invalid date length for AI $aiCode: expected ${ai.fixLength}, got ${value.length}'); + } + + if (value.length < 6) { + throw GS1ParseException(message: 'Date value too short for AI $aiCode'); + } + + var year = _year2ToYear4(int.parse(value.substring(0, 2), radix: 10)); + final month = int.parse(value.substring(2, 4), radix: 10); + final day = int.parse(value.substring(4), radix: 10); + + return GS1ParsedElement( + rawData: value, + aiCode: aiCode, + data: DateTime(year, month, day), + ); + } + _year2ToYear4(int year) { return year > 50 ? year + 1900 : year = year + 2000; } @@ -84,7 +103,16 @@ class GS1DateTimeParser extends GS1ElementParser { } final rawData = elementStr.substring(ai.code.length); - final elementDateTime = rawData.padRight(12, '0'); + final element = parseFromParts(ai.code, rawData, ai, config); + + final rest = getRest(data, offset, config); + return ParsedElementWithRest(element: element, rest: rest); + } + + @override + GS1ParsedElement parseFromParts( + String aiCode, String value, AI ai, GS1BarcodeParserConfig config) { + final elementDateTime = value.padRight(12, '0'); var year = _year2ToYear4(int.parse(elementDateTime.substring(0, 2), radix: 10)); final month = int.parse(elementDateTime.substring(2, 4), radix: 10); @@ -93,14 +121,11 @@ class GS1DateTimeParser extends GS1ElementParser { final minute = int.parse(elementDateTime.substring(8, 10), radix: 10); final second = int.parse(elementDateTime.substring(10), radix: 10); - final element = GS1ParsedElement( - rawData: rawData, - aiCode: ai.code, + return GS1ParsedElement( + rawData: value, + aiCode: aiCode, data: DateTime(year, month, day, hour, minute, second), ); - - final rest = getRest(data, offset, config); - return ParsedElementWithRest(element: element, rest: rest); } _year2ToYear4(int year) { @@ -121,16 +146,27 @@ class GS1ElementFixLengthParser extends GS1ElementParser { message: 'Data format mismatch ${ai.regExp} for AI ${ai.code}'); } final elementValue = elementStr.substring(ai.code.length); - - final element = GS1ParsedElement( - rawData: elementValue, - aiCode: ai.code, - data: elementValue, - ); + final element = parseFromParts(ai.code, elementValue, ai, config); final rest = getRest(data, offset, config); return ParsedElementWithRest(element: element, rest: rest); } + + @override + GS1ParsedElement parseFromParts( + String aiCode, String value, AI ai, GS1BarcodeParserConfig config) { + if (value.length != ai.fixLength) { + throw GS1ParseException( + message: + 'Fixed length mismatch for AI $aiCode: expected ${ai.fixLength}, got ${value.length}'); + } + + return GS1ParsedElement( + rawData: value, + aiCode: aiCode, + data: value, + ); + } } class GS1ElementFixLengthMeasureParser extends GS1ElementParser { @@ -147,15 +183,28 @@ class GS1ElementFixLengthMeasureParser extends GS1ElementParser { } final elementValue = elementStr.substring(ai.code.length); - - final element = GS1ParsedElement( - rawData: elementValue, - aiCode: ai.code, - data: parseFloatingPoint(elementValue, ai.numberOfDecimalPlaces)); + final element = parseFromParts(ai.code, elementValue, ai, config); final rest = getRest(data, offset, config); return ParsedElementWithRest(element: element, rest: rest); } + + @override + GS1ParsedElement parseFromParts( + String aiCode, String value, AI ai, GS1BarcodeParserConfig config) { + if (value.isEmpty) { + throw GS1ParseException(message: 'Empty measure value for AI $aiCode'); + } + if (value.length != ai.fixLength) { + throw GS1ParseException( + message: + 'Fixed length mismatch for AI $aiCode: expected ${ai.fixLength}, got ${value.length}'); + } + + final doubleValue = parseFloatingPoint(value, ai.numberOfDecimalPlaces); + return GS1ParsedElement( + rawData: value, aiCode: aiCode, data: doubleValue); + } } class GS1VariableLengthParser extends GS1ElementParser { @@ -172,12 +221,7 @@ class GS1VariableLengthParser extends GS1ElementParser { } final elementValue = elementStr.substring(ai.code.length); - - final element = GS1ParsedElement( - aiCode: ai.code, - rawData: elementValue, - data: elementValue, - ); + final element = parseFromParts(ai.code, elementValue, ai, config); final rest = getRest(data, offset, config); return ParsedElementWithRest( @@ -185,6 +229,16 @@ class GS1VariableLengthParser extends GS1ElementParser { rest: rest, ); } + + @override + GS1ParsedElement parseFromParts( + String aiCode, String value, AI ai, GS1BarcodeParserConfig config) { + return GS1ParsedElement( + aiCode: aiCode, + rawData: value, + data: value, + ); + } } class GS1VariableLengthMeasureParser extends GS1ElementParser { @@ -201,13 +255,22 @@ class GS1VariableLengthMeasureParser extends GS1ElementParser { } final numberPart = data.substring(ai.code.length, offset); + final element = parseFromParts(ai.code, numberPart, ai, config); final rest = getRest(data, offset, config); - final element = GS1ParsedElement( - rawData: numberPart, - aiCode: ai.code, - data: parseFloatingPoint(numberPart, ai.numberOfDecimalPlaces)); return ParsedElementWithRest(element: element, rest: rest); } + + @override + GS1ParsedElement parseFromParts( + String aiCode, String value, AI ai, GS1BarcodeParserConfig config) { + if (value.isEmpty) { + throw GS1ParseException(message: 'Empty measure value for AI $aiCode'); + } + + final doubleValue = parseFloatingPoint(value, ai.numberOfDecimalPlaces); + return GS1ParsedElement( + rawData: value, aiCode: aiCode, data: doubleValue); + } } class GS1VariableLengthWithISONumbersParser extends GS1ElementParser { @@ -223,18 +286,28 @@ class GS1VariableLengthWithISONumbersParser extends GS1ElementParser { message: 'Data format mismatch ${ai.regExp} for AI ${ai.code}'); } - final numberPart = elementStr.substring(ai.code.length + 3); - final isoPart = elementStr.substring(ai.code.length, ai.code.length + 3); - + final rawValue = elementStr.substring(ai.code.length); + final element = parseFromParts(ai.code, rawValue, ai, config); final rest = getRest(data, offset, config); - final element = GS1ParsedElement( - rawData: elementStr.substring(ai.code.length), - aiCode: ai.code, - iso: isoPart, - data: parseFloatingPoint(numberPart, ai.numberOfDecimalPlaces)); return ParsedElementWithRest(element: element, rest: rest); } + + @override + GS1ParsedElement parseFromParts( + String aiCode, String value, AI ai, GS1BarcodeParserConfig config) { + if (value.length < 3) { + throw GS1ParseException( + message: 'Value too short for ISO numbers AI $aiCode'); + } + + final isoPart = value.substring(0, 3); + final numberPart = value.substring(3); + final doubleValue = + parseFloatingPoint(numberPart, ai.numberOfDecimalPlaces); + return GS1ParsedElement( + rawData: value, aiCode: aiCode, iso: isoPart, data: doubleValue); + } } class GS1VariableLengthWithISOCharsParser extends GS1ElementParser { @@ -250,17 +323,29 @@ class GS1VariableLengthWithISOCharsParser extends GS1ElementParser { message: 'Data format mismatch ${ai.regExp} for AI ${ai.code}'); } - final charPart = elementStr.substring(ai.code.length + 3); - final isoPart = elementStr.substring(ai.code.length, ai.code.length + 3); - + final rawValue = elementStr.substring(ai.code.length); + final element = parseFromParts(ai.code, rawValue, ai, config); final rest = getRest(data, offset, config); - final element = GS1ParsedElement( - rawData: elementStr.substring(ai.code.length), - aiCode: ai.code, + + return ParsedElementWithRest(element: element, rest: rest); + } + + @override + GS1ParsedElement parseFromParts( + String aiCode, String value, AI ai, GS1BarcodeParserConfig config) { + if (value.length < 3) { + throw GS1ParseException( + message: 'Value too short for ISO chars AI $aiCode'); + } + + final isoPart = value.substring(0, 3); + final charPart = value.substring(3); + + return GS1ParsedElement( + rawData: value, + aiCode: aiCode, iso: isoPart, data: charPart, ); - - return ParsedElementWithRest(element: element, rest: rest); } }