Skip to content

Commit

Permalink
Configurable line separator
Browse files Browse the repository at this point in the history
  • Loading branch information
manuelbl committed Jan 28, 2024
1 parent a4c878c commit 5f9093c
Show file tree
Hide file tree
Showing 8 changed files with 113 additions and 38 deletions.
2 changes: 1 addition & 1 deletion generator/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ repositories {
}

group = 'net.codecrete.qrbill'
version = '3.2.0'
version = '3.2.0-SNAPSHOT'
archivesBaseName = 'qrbill-generator'

sourceCompatibility = 1.8
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -316,6 +319,30 @@ public void setLocalCountryCode(String localCountryCode) {
this.localCountryCode = localCountryCode;
}

/**
* Gets the line separator for the QR code data fields.
* <p>
* 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.
* </p>
* @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.
* <p>
* 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.
* </p>
* @param qrDataSeparator the line separator for the QR code data fields.
*/
public void setQrDataSeparator(QrDataSeparator qrDataSeparator) {
this.qrDataSeparator = qrDataSeparator;
}

/**
* {@inheritDoc}
*/
Expand All @@ -332,17 +359,17 @@ 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;
}

/**
* {@inheritDoc}
*/
@Override
public int hashCode() {

return Objects.hash(outputSize, language, separatorType, fontFamily, graphicsFormat, resolution, marginLeft,
marginLeft, localCountryCode);
marginLeft, localCountryCode, qrDataSeparator);
}

/**
Expand All @@ -360,6 +387,7 @@ public String toString() {
", marginLeft=" + marginLeft +
", marginRight=" + marginRight +
", localCountryCode='" + localCountryCode + '\'' +
", qrDataSeparator=" + qrDataSeparator +
'}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand All @@ -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
Expand Down Expand Up @@ -91,15 +92,16 @@ 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);
}
}

private void appendDataField(String value) {
if (value == null)
value = "";

textBuilder.append('\n').append(value);
textBuilder.append(dataSeparator).append(value);
}

private static DecimalFormat createAmountFormatter() {
Expand Down Expand Up @@ -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())
Expand All @@ -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)
Expand All @@ -193,8 +206,6 @@ public static Bill decode(String text) {
}
}
bill.setAlternativeSchemes(alternativeSchemes);

return bill;
}

/**
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

Expand All @@ -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));
}

Expand All @@ -45,4 +48,13 @@ void createTextEmptyReference() {
bill.setReference("");
assertEquals(SampleQrCodeText.getQrCodeText(3), QRCodeText.create(bill));
}

private static Stream<Arguments> provideNewLineSampleCombinations() {
Stream.Builder<Arguments> builder = Stream.builder();
for (int sample = 1; sample <= 5; sample++) {
builder.add(Arguments.of(sample, QrDataSeparator.LF));
builder.add(Arguments.of(sample, QrDataSeparator.CR_LF));
}
return builder.build();
}
}

0 comments on commit 5f9093c

Please sign in to comment.