diff --git a/generator/build.gradle b/generator/build.gradle index edebaba..865c493 100644 --- a/generator/build.gradle +++ b/generator/build.gradle @@ -17,7 +17,7 @@ repositories { } group = 'net.codecrete.qrbill' -version = '3.2.0' +version = '3.2.0-SNAPSHOT' archivesBaseName = 'qrbill-generator' sourceCompatibility = 1.8 diff --git a/generator/src/main/java/net/codecrete/qrbill/generator/BillFormat.java b/generator/src/main/java/net/codecrete/qrbill/generator/BillFormat.java index 09a5a22..246cd02 100644 --- a/generator/src/main/java/net/codecrete/qrbill/generator/BillFormat.java +++ b/generator/src/main/java/net/codecrete/qrbill/generator/BillFormat.java @@ -41,6 +41,9 @@ public class BillFormat implements Serializable { /** ISO country code of local country */ private String localCountryCode = "CH"; + /** Data separator for QR code data */ + private QrDataSeparator qrDataSeparator = QrDataSeparator.LF; + /** * Creates a new instance with default values */ @@ -316,6 +319,30 @@ public void setLocalCountryCode(String localCountryCode) { this.localCountryCode = localCountryCode; } + /** + * Gets the line separator for the QR code data fields. + *
+ * The default is {@link QrDataSeparator#LF}. There is no need to change it except + * for improving compatibility with a non-compliant software processing the QR code data. + *
+ * @return the line separator for the QR code data fields. + */ + public QrDataSeparator getQrDataSeparator() { + return qrDataSeparator; + } + + /** + * Sets the line separator for the QR code data fields. + *+ * The default is {@link QrDataSeparator#LF}. There is no need to change it except + * for improving compatibility with a non-compliant software processing the QR code data. + *
+ * @param qrDataSeparator the line separator for the QR code data fields. + */ + public void setQrDataSeparator(QrDataSeparator qrDataSeparator) { + this.qrDataSeparator = qrDataSeparator; + } + /** * {@inheritDoc} */ @@ -332,7 +359,8 @@ public boolean equals(Object o) { resolution == that.resolution && marginLeft == that.marginLeft && marginRight == that.marginRight && - Objects.equals(localCountryCode, that.localCountryCode); + Objects.equals(localCountryCode, that.localCountryCode) && + qrDataSeparator == that.qrDataSeparator; } /** @@ -340,9 +368,8 @@ public boolean equals(Object o) { */ @Override public int hashCode() { - return Objects.hash(outputSize, language, separatorType, fontFamily, graphicsFormat, resolution, marginLeft, - marginLeft, localCountryCode); + marginLeft, localCountryCode, qrDataSeparator); } /** @@ -360,6 +387,7 @@ public String toString() { ", marginLeft=" + marginLeft + ", marginRight=" + marginRight + ", localCountryCode='" + localCountryCode + '\'' + + ", qrDataSeparator=" + qrDataSeparator + '}'; } } diff --git a/generator/src/main/java/net/codecrete/qrbill/generator/QRCodeText.java b/generator/src/main/java/net/codecrete/qrbill/generator/QRCodeText.java index 1fb5edc..6d9b6c8 100644 --- a/generator/src/main/java/net/codecrete/qrbill/generator/QRCodeText.java +++ b/generator/src/main/java/net/codecrete/qrbill/generator/QRCodeText.java @@ -20,10 +20,13 @@ public class QRCodeText { private final Bill bill; - private StringBuilder textBuilder; + private final StringBuilder textBuilder; + private final String dataSeparator; private QRCodeText(Bill bill) { this.bill = bill; + textBuilder = new StringBuilder(); + dataSeparator = bill.getFormat().getQrDataSeparator() == QrDataSeparator.CR_LF ? "\r\n" : "\n"; } /** @@ -39,17 +42,15 @@ public static String create(Bill bill) { } private String createText() { - textBuilder = new StringBuilder(); - // Header - textBuilder.append("SPC\n"); // QRType - textBuilder.append("0200\n"); // Version - textBuilder.append("1"); // Coding + textBuilder.append("SPC"); // QRType + appendDataField("0200"); // Version + appendDataField("1"); // Coding // CdtrInf appendDataField(bill.getAccount()); // IBAN appendPerson(bill.getCreditor()); // Cdtr - textBuilder.append("\n\n\n\n\n\n\n"); // UltmtCdtr + appendPerson(null); // UltmtCdtr // CcyAmt appendDataField(bill.getAmount() == null ? "" : formatAmountForCode(bill.getAmount())); // Amt @@ -91,7 +92,8 @@ private void appendPerson(Address address) { appendDataField(address.getTown()); // TwnNm appendDataField(address.getCountryCode()); // Ctry } else { - textBuilder.append("\n\n\n\n\n\n\n"); + for (int i = 0; i < 7; i++) + appendDataField(null); } } @@ -99,7 +101,7 @@ private void appendDataField(String value) { if (value == null) value = ""; - textBuilder.append('\n').append(value); + textBuilder.append(dataSeparator).append(value); } private static DecimalFormat createAmountFormatter() { @@ -135,26 +137,17 @@ private static String formatAmountForCode(BigDecimal amount) { */ public static Bill decode(String text) { String[] lines = splitLines(text); - if (lines.length < 31 || lines.length > 34) { - // A line feed at the end is illegal (cf 4.2.3) but found in practice. Don't be too strict. - if (!(lines.length == 35 && lines[34].isEmpty())) - throwSingleValidationError(ValidationConstants.FIELD_QR_TYPE, ValidationConstants.KEY_DATA_STRUCTURE_INVALID); - } - if (!"SPC".equals(lines[0])) - throwSingleValidationError(ValidationConstants.FIELD_QR_TYPE, ValidationConstants.KEY_DATA_STRUCTURE_INVALID); - if (!VALID_VERSION.matcher(lines[1]).matches()) - throwSingleValidationError(ValidationConstants.FIELD_VERSION, ValidationConstants.KEY_VERSION_UNSUPPORTED); - if (!"1".equals(lines[2])) - throwSingleValidationError(ValidationConstants.FIELD_CODING_TYPE, ValidationConstants.KEY_CODING_TYPE_UNSUPPORTED); + validateHeader(lines); Bill bill = new Bill(); bill.setVersion(Bill.Version.V2_0); + bill.getFormat().setQrDataSeparator(text.contains("\r\n") ? QrDataSeparator.CR_LF : QrDataSeparator.LF); bill.setAccount(lines[3]); bill.setCreditor(decodeAddress(lines, 4, false)); - if (lines[18].length() > 0) { + if (!lines[18].isEmpty()) { ParsePosition position = new ParsePosition(0); BigDecimal amount = (BigDecimal) createAmountFormatter().parse(lines[18], position); if (position.getIndex() == lines[18].length()) @@ -179,6 +172,26 @@ public static Bill decode(String text) { bill.setBillInformation(lines.length > 31 ? lines[31] : ""); + decodeAlternativeSchemes(lines, bill); + + return bill; + } + + private static void validateHeader(String[] lines) { + if (lines.length < 31 || lines.length > 34) { + // A line feed at the end is illegal (cf 4.2.3) but found in practice. Don't be too strict. + if (!(lines.length == 35 && lines[34].isEmpty())) + throwSingleValidationError(ValidationConstants.FIELD_QR_TYPE, ValidationConstants.KEY_DATA_STRUCTURE_INVALID); + } + if (!"SPC".equals(lines[0])) + throwSingleValidationError(ValidationConstants.FIELD_QR_TYPE, ValidationConstants.KEY_DATA_STRUCTURE_INVALID); + if (!VALID_VERSION.matcher(lines[1]).matches()) + throwSingleValidationError(ValidationConstants.FIELD_VERSION, ValidationConstants.KEY_VERSION_UNSUPPORTED); + if (!"1".equals(lines[2])) + throwSingleValidationError(ValidationConstants.FIELD_CODING_TYPE, ValidationConstants.KEY_CODING_TYPE_UNSUPPORTED); + } + + private static void decodeAlternativeSchemes(String[] lines, Bill bill) { AlternativeScheme[] alternativeSchemes = null; int numSchemes = lines.length - 32; // skip empty schemes at end (due to invalid line feed at end) @@ -193,8 +206,6 @@ public static Bill decode(String text) { } } bill.setAlternativeSchemes(alternativeSchemes); - - return bill; } /** @@ -207,10 +218,10 @@ public static Bill decode(String text) { */ private static Address decodeAddress(String[] lines, int startLine, boolean isOptional) { - boolean isEmpty = lines[startLine].length() == 0 && lines[startLine + 1].length() == 0 - && lines[startLine + 2].length() == 0 && lines[startLine + 3].length() == 0 - && lines[startLine + 4].length() == 0 && lines[startLine + 5].length() == 0 - && lines[startLine + 6].length() == 0; + boolean isEmpty = lines[startLine].isEmpty() && lines[startLine + 1].isEmpty() + && lines[startLine + 2].isEmpty() && lines[startLine + 3].isEmpty() + && lines[startLine + 4].isEmpty() && lines[startLine + 5].isEmpty() + && lines[startLine + 6].isEmpty(); if (isEmpty && isOptional) return null; @@ -225,9 +236,9 @@ private static Address decodeAddress(String[] lines, int startLine, boolean isOp address.setAddressLine1(lines[startLine + 2]); address.setAddressLine2(lines[startLine + 3]); } - if (lines[startLine + 4].length() > 0) + if (!lines[startLine + 4].isEmpty()) address.setPostalCode(lines[startLine + 4]); - if (lines[startLine + 5].length() > 0) + if (!lines[startLine + 5].isEmpty()) address.setTown(lines[startLine + 5]); address.setCountryCode(lines[startLine + 6]); return address; diff --git a/generator/src/main/java/net/codecrete/qrbill/generator/QrDataSeparator.java b/generator/src/main/java/net/codecrete/qrbill/generator/QrDataSeparator.java new file mode 100644 index 0000000..23bf259 --- /dev/null +++ b/generator/src/main/java/net/codecrete/qrbill/generator/QrDataSeparator.java @@ -0,0 +1,22 @@ +// +// Swiss QR Bill Generator +// Copyright (c) 2024 Manuel Bleichenbacher +// Licensed under MIT License +// https://opensource.org/licenses/MIT +// +package net.codecrete.qrbill.generator; + +/** + * Allowed line separators for separating the data fields in the text represented by the QR code + * (according to the Swiss Implementation Guidelines for the QR-bill, ยง 4.1.4 Separator element) + */ +public enum QrDataSeparator { + /** + * Separate lines with the line feed (โ) character, i.e. unicode U+000A. + */ + LF, + /** + * Separate lines with the carriage return (โ) character and line feed (โ) characters, i.e. unicode U+000D and U+000A. + */ + CR_LF +} diff --git a/generator/src/test/java/net/codecrete/qrbill/generator/BillFormatTest.java b/generator/src/test/java/net/codecrete/qrbill/generator/BillFormatTest.java index a8ec837..6ad5fdf 100644 --- a/generator/src/test/java/net/codecrete/qrbill/generator/BillFormatTest.java +++ b/generator/src/test/java/net/codecrete/qrbill/generator/BillFormatTest.java @@ -38,7 +38,7 @@ void hashCodeTest() { void toStringTest() { BillFormat format = new BillFormat(); String text = format.toString(); - assertEquals("BillFormat{outputSize=QR_BILL_ONLY, language=EN, separatorType=DASHED_LINE_WITH_SCISSORS, fontFamily='Helvetica,Arial,\"Liberation Sans\"', graphicsFormat=SVG, resolution=144, marginLeft=5.0, marginRight=5.0, localCountryCode='CH'}", text); + assertEquals("BillFormat{outputSize=QR_BILL_ONLY, language=EN, separatorType=DASHED_LINE_WITH_SCISSORS, fontFamily='Helvetica,Arial,\"Liberation Sans\"', graphicsFormat=SVG, resolution=144, marginLeft=5.0, marginRight=5.0, localCountryCode='CH', qrDataSeparator=LF}", text); } @Test diff --git a/generator/src/test/java/net/codecrete/qrbill/generator/BillTest.java b/generator/src/test/java/net/codecrete/qrbill/generator/BillTest.java index aefc538..d0eca4a 100644 --- a/generator/src/test/java/net/codecrete/qrbill/generator/BillTest.java +++ b/generator/src/test/java/net/codecrete/qrbill/generator/BillTest.java @@ -183,7 +183,7 @@ void testHashCode() { void testToString() { Bill bill = createBill(); String text = bill.toString(); - assertEquals("Bill{version=V2_0, amount=100.30, currency='CHF', account='CH12343345345', creditor=Address{type=STRUCTURED, name='Vision Consult GmbH', addressLine1='null', addressLine2='null', street='Hintergasse', houseNo='7b', postalCode='8400', town='Winterthur', countryCode='CH'}, referenceType='NON', reference='null', debtor=Address{type=STRUCTURED, name='Vision Consult GmbH', addressLine1='null', addressLine2='null', street='Hintergasse', houseNo='7b', postalCode='8400', town='Winterthur', countryCode='CH'}, unstructuredMessage='null', billInformation='null', alternativeSchemes=null, format=BillFormat{outputSize=QR_BILL_ONLY, language=EN, separatorType=DASHED_LINE_WITH_SCISSORS, fontFamily='Helvetica,Arial,\"Liberation Sans\"', graphicsFormat=SVG, resolution=144, marginLeft=5.0, marginRight=5.0, localCountryCode='CH'}}", text); + assertEquals("Bill{version=V2_0, amount=100.30, currency='CHF', account='CH12343345345', creditor=Address{type=STRUCTURED, name='Vision Consult GmbH', addressLine1='null', addressLine2='null', street='Hintergasse', houseNo='7b', postalCode='8400', town='Winterthur', countryCode='CH'}, referenceType='NON', reference='null', debtor=Address{type=STRUCTURED, name='Vision Consult GmbH', addressLine1='null', addressLine2='null', street='Hintergasse', houseNo='7b', postalCode='8400', town='Winterthur', countryCode='CH'}, unstructuredMessage='null', billInformation='null', alternativeSchemes=null, format=BillFormat{outputSize=QR_BILL_ONLY, language=EN, separatorType=DASHED_LINE_WITH_SCISSORS, fontFamily='Helvetica,Arial,\"Liberation Sans\"', graphicsFormat=SVG, resolution=144, marginLeft=5.0, marginRight=5.0, localCountryCode='CH', qrDataSeparator=LF}}", text); } private Address createAddress() { diff --git a/generator/src/test/java/net/codecrete/qrbill/generator/DecodedTextTest.java b/generator/src/test/java/net/codecrete/qrbill/generator/DecodedTextTest.java index b3d05b7..a239bb0 100644 --- a/generator/src/test/java/net/codecrete/qrbill/generator/DecodedTextTest.java +++ b/generator/src/test/java/net/codecrete/qrbill/generator/DecodedTextTest.java @@ -66,7 +66,9 @@ void decodeText4() { @ParameterizedTest @MethodSource("provideNewLineCombinations") void decodeTextNewline(int sample, String newLine, boolean extraNewLine) { + QrDataSeparator separator = newLine.equals("\r\n") ? QrDataSeparator.CR_LF : QrDataSeparator.LF; Bill bill = SampleQrCodeText.getBillData(sample); + bill.getFormat().setQrDataSeparator(separator); normalizeSourceBill(bill); Bill bill2 = QRBill.decodeQrCodeText(SampleQrCodeText.getQrCodeText(sample, newLine) + (extraNewLine ? newLine : "")); normalizeDecodedBill(bill2); diff --git a/generator/src/test/java/net/codecrete/qrbill/generator/EncodedTextTest.java b/generator/src/test/java/net/codecrete/qrbill/generator/EncodedTextTest.java index 2fc0104..dcccba4 100644 --- a/generator/src/test/java/net/codecrete/qrbill/generator/EncodedTextTest.java +++ b/generator/src/test/java/net/codecrete/qrbill/generator/EncodedTextTest.java @@ -10,9 +10,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.math.BigDecimal; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; @@ -23,9 +25,10 @@ class EncodedTextTest { @ParameterizedTest - @ValueSource(ints = {1, 2, 3, 4, 5}) - void createText(int sample) { + @MethodSource("provideNewLineSampleCombinations") + void createText(int sample, QrDataSeparator separator) { Bill bill = SampleQrCodeText.getBillData(sample); + bill.getFormat().setQrDataSeparator(separator); assertEquals(SampleQrCodeText.getQrCodeText(sample), QRBill.encodeQrCodeText(bill)); } @@ -45,4 +48,13 @@ void createTextEmptyReference() { bill.setReference(""); assertEquals(SampleQrCodeText.getQrCodeText(3), QRCodeText.create(bill)); } + + private static Stream