diff --git a/.github/workflows/master-build.yml b/.github/workflows/master-build.yml
index 05b9fc31706..97cb4475dbd 100644
--- a/.github/workflows/master-build.yml
+++ b/.github/workflows/master-build.yml
@@ -1,15 +1,18 @@
name: Build
+
on:
push:
branches:
- master
+ paths-ignore:
+ - 'docs/**'
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@4
+ - uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -50,21 +53,30 @@ jobs:
ports:
- 9200:9200
- 9300:9300
- options: -e="discovery.type=single-node" -e="xpack.security.enabled=false" --health-cmd="curl http://localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=10
+ options: >-
+ -e="discovery.type=single-node"
+ -e="xpack.security.enabled=false"
+ --health-cmd="curl http://localhost:9200/_cluster/health"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=10
minio:
image: docker.io/bawix/minio:2022
ports:
- 9000:9000
- 9001:9001
- options: -e="MINIO_ROOT_USER=root" -e="MINIO_ROOT_PASSWORD=password" -e="MINIO_DEFAULT_BUCKETS=default"
+ options: >-
+ -e="MINIO_ROOT_USER=root"
+ -e="MINIO_ROOT_PASSWORD=password"
+ -e="MINIO_DEFAULT_BUCKETS=default"
steps:
- - name: Test Database
+ - name: Test Elasticsearch health
env:
ELASTIC_SEARCH_URL: http://localhost:${{ job.services.elasticsearch.ports[9200] }}
run: |
- echo $ELASTIC_SEARCH_URL
+ echo "Elasticsearch URL: $ELASTIC_SEARCH_URL"
curl -fsSL "$ELASTIC_SEARCH_URL/_cat/health?h=status"
- uses: actions/checkout@v4
@@ -92,7 +104,12 @@ jobs:
restore-keys: ${{ runner.os }}-m2
- name: Generate certificates
- run: cd application-engine/src/main/resources/certificates && openssl genrsa -out keypair.pem 4096 && openssl rsa -in keypair.pem -pubout -out public.crt && openssl pkcs8 -topk8 -inform PEM -outform DER -nocrypt -in keypair.pem -out private.der && cd ../../../../..
+ run: |
+ cd application-engine/src/main/resources/certificates
+ openssl genrsa -out keypair.pem 4096
+ openssl rsa -in keypair.pem -pubout -out public.crt
+ openssl pkcs8 -topk8 -inform PEM -outform DER -nocrypt -in keypair.pem -out private.der
+ cd ../../../../..
- name: Build
run: mvn clean package install -DskipTests=true
@@ -103,7 +120,7 @@ jobs:
# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=netgrif_application-engine
- - name: Build, test
+ - name: Test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: mvn -B verify
@@ -139,8 +156,10 @@ jobs:
mvn javadoc:javadoc
cp -r ./application-engine/target/reports/apidocs/* ./docs/javadoc/
- - uses: EndBug/add-and-commit@v9
- with:
- add: docs
- pathspec_error_handling: exitImmediately
- message: 'CI - Update documentation'
+ - name: Commit docs if changed
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add docs
+ git diff --staged --quiet || git commit -m "CI - Update documentation"
+ git push
\ No newline at end of file
diff --git a/application-engine/pom.xml b/application-engine/pom.xml
index 583df212b1a..b22f9aaec13 100644
--- a/application-engine/pom.xml
+++ b/application-engine/pom.xml
@@ -70,10 +70,6 @@
true
-
- jitpack.io
- https://jitpack.io
-
@@ -359,9 +355,14 @@
- com.github.kenglxn.qrgen
+ com.google.zxing
+ core
+ 3.5.4
+
+
+ com.google.zxing
javase
- 3.0.1
+ 3.5.4
diff --git a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrCode.java b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrCode.java
index 8c6d3ee1b30..244baa510ce 100644
--- a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrCode.java
+++ b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrCode.java
@@ -7,17 +7,22 @@
@Data
public class QrCode {
+ public static final double DEFAULT_LOGO_RATIO = 0.15;
+ public static final int DEFAULT_LOGO_BACKGROUND_PADDING = 4;
+ public static final int DEFAULT_LOGO_BACKGROUND_ARC = 8;
+ public static final QrImageType DEFAULT_IMAGE_TYPE = QrImageType.PNG;
+
private String content;
private String fileName;
- private ImageType imageType = ImageType.JPG;
+ private QrImageType imageType = DEFAULT_IMAGE_TYPE;
private int width = 250;
private int height = 250;
- private ErrorCorrectionLevel errorCorrectionLevel = ErrorCorrectionLevel.L;
+ private ErrorCorrectionLevel errorCorrectionLevel = ErrorCorrectionLevel.H;
private int onColor = 0xFF000000;
@@ -25,6 +30,12 @@ public class QrCode {
private String charset = "ISO-8859-1";
+ private double logoRatio = DEFAULT_LOGO_RATIO;
+
+ private int logoBackgroundPadding = DEFAULT_LOGO_BACKGROUND_PADDING;
+
+ private int logoBackgroundArc = DEFAULT_LOGO_BACKGROUND_ARC;
+
public QrCode(String fileName, String content) {
this.fileName = fileName;
this.content = content;
diff --git a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrImageType.java b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrImageType.java
new file mode 100644
index 00000000000..0fcc4e7d9cd
--- /dev/null
+++ b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrImageType.java
@@ -0,0 +1,20 @@
+package com.netgrif.application.engine.business.qr;
+
+public enum QrImageType {
+
+ PNG("png"),
+ JPG("jpg"),
+ JPEG("jpeg"),
+ GIF("gif"),
+ BMP("bmp");
+
+ private final String format;
+
+ QrImageType(String format) {
+ this.format = format;
+ }
+
+ public String getFormat() {
+ return format;
+ }
+}
\ No newline at end of file
diff --git a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrService.java b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrService.java
index 17a337360de..c5057deca32 100644
--- a/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrService.java
+++ b/application-engine/src/main/java/com/netgrif/application/engine/business/qr/QrService.java
@@ -1,92 +1,242 @@
package com.netgrif.application.engine.business.qr;
import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageConfig;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
-import net.glxn.qrgen.javase.QRCode;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import com.google.zxing.qrcode.QRCodeWriter;
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
-import java.awt.*;
+import java.awt.AlphaComposite;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.file.Files;
-import java.nio.file.Paths;
-import java.nio.file.StandardCopyOption;
+import java.nio.file.Path;
+import java.util.EnumMap;
+import java.util.Map;
import java.util.Optional;
+@Slf4j
@Service
public class QrService implements IQrService {
- private static Logger log = LoggerFactory.getLogger(QrService.class);
@Override
public Optional generateToStream(QrCode code) {
- try {
- return Optional.of(new FileInputStream(generateFile(code)));
- } catch (FileNotFoundException e) {
- log.error("Error creating qr code.", e);
+ log.debug("Generating QR code to stream [file={}, size={}x{}, errorCorrection={}]", code.getFileName(), code.getWidth(), code.getHeight(), code.getErrorCorrectionLevel());
+ try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
+ BitMatrix bitMatrix = createBitMatrix(code);
+ MatrixToImageConfig config = new MatrixToImageConfig(code.getOnColor(), code.getOffColor());
+ String format = resolveImageFormat(code);
+
+ MatrixToImageWriter.writeToStream(bitMatrix, format, os, config);
+
+ log.trace("QR code stream generated successfully [file={}, bytes={}]", code.getFileName(), os.size());
+ return Optional.of(new ByteArrayInputStream(os.toByteArray()));
+ } catch (WriterException | IOException e) {
+ log.error("Failed to generate QR code to stream [file={}]", code.getFileName(), e);
return Optional.empty();
}
}
@Override
public Optional generateToFile(QrCode code) {
+ log.debug("Generating QR code to file [file={}, size={}x{}, errorCorrection={}]", code.getFileName(), code.getWidth(), code.getHeight(), code.getErrorCorrectionLevel());
try {
- return Optional.ofNullable(generateFile(code));
- } catch (Exception e) {
- log.error("Error creating qr code.", e);
+ File result = generateFile(code);
+ log.trace("QR code file generated successfully [file={}, sizeBytes={}]", result.getAbsolutePath(), result.length());
+ return Optional.of(result);
+ } catch (WriterException | IOException e) {
+ log.error("Failed to generate QR code to file [file={}]", code.getFileName(), e);
return Optional.empty();
}
}
@Override
public Optional generateWithLogo(QrCode code, InputStream imageStream) {
+ log.debug("Generating QR code with logo [file={}, size={}x{}, errorCorrection={}, logoRatio={}]", code.getFileName(), code.getWidth(), code.getHeight(), code.getErrorCorrectionLevel(), code.getLogoRatio());
+
+ validateLogoErrorCorrection(code);
+
+ BufferedImage overlay = readLogoImage(imageStream, code.getFileName());
+ if (overlay == null) {
+ return Optional.empty();
+ }
+
try {
- ByteArrayOutputStream os = new ByteArrayOutputStream();
- BitMatrix bitMatrix = QRCode.from(code.getContent())
- .to(code.getImageType())
- .withSize(code.getWidth(), code.getHeight())
- .withColor(code.getOnColor(), code.getOffColor())
- .withErrorCorrection(code.getErrorCorrectionLevel())
- .withCharset(code.getCharset())
- .getQrWriter().encode(code.getContent(), BarcodeFormat.QR_CODE, code.getWidth(), code.getHeight());
+ BitMatrix bitMatrix = createBitMatrix(code);
MatrixToImageConfig config = new MatrixToImageConfig(code.getOnColor(), code.getOffColor());
-
BufferedImage qrImage = MatrixToImageWriter.toBufferedImage(bitMatrix, config);
- BufferedImage overly = ImageIO.read(imageStream);
- int deltaHeight = qrImage.getHeight() - overly.getHeight();
- int deltaWidth = qrImage.getWidth() - overly.getWidth();
+ log.trace("QR BitMatrix created [file={}, qrSize={}x{}]", code.getFileName(), qrImage.getWidth(), qrImage.getHeight());
+
+ BufferedImage scaledLogo = scaleLogo(overlay, qrImage.getWidth(), qrImage.getHeight(), code.getLogoRatio());
+ BufferedImage combined = compositeLogoOnQr(qrImage, scaledLogo, code);
+ writeImageToFile(combined, code);
+
+ File result = new File(code.getFileName());
+ log.trace("QR code with logo written to disk [file={}, sizeBytes={}]", result.getAbsolutePath(), result.length());
+ return Optional.of(result);
+ } catch (WriterException | IOException e) {
+ log.error("Failed to generate QR code with logo [file={}]", code.getFileName(), e);
+ return Optional.empty();
+ }
+ }
+
+ private File generateFile(QrCode code) throws WriterException, IOException {
+ BitMatrix bitMatrix = createBitMatrix(code);
+ MatrixToImageConfig config = new MatrixToImageConfig(code.getOnColor(), code.getOffColor());
+ Path outputPath = Path.of(code.getFileName());
+
+ try (OutputStream outputStream = Files.newOutputStream(outputPath)) {
+ MatrixToImageWriter.writeToStream(bitMatrix, resolveImageFormat(code), outputStream, config);
+ } catch (IOException e) {
+ log.error("Failed to write QR code to file [file={}, path={}]", code.getFileName(), outputPath.toAbsolutePath(), e);
+ throw e;
+ }
+
+ return outputPath.toFile();
+ }
+
+ private BitMatrix createBitMatrix(QrCode code) throws WriterException {
+ Map hints = new EnumMap<>(EncodeHintType.class);
+ hints.put(EncodeHintType.CHARACTER_SET, code.getCharset());
+ hints.put(EncodeHintType.ERROR_CORRECTION, code.getErrorCorrectionLevel());
+ hints.put(EncodeHintType.MARGIN, 1);
+
+ log.trace("Encoding BitMatrix [file={}, charset={}, errorCorrection={}, margin=1]", code.getFileName(), code.getCharset(), code.getErrorCorrectionLevel());
+
+ return new QRCodeWriter().encode(
+ code.getContent(),
+ BarcodeFormat.QR_CODE,
+ code.getWidth(),
+ code.getHeight(),
+ hints
+ );
+ }
+
+ private BufferedImage readLogoImage(InputStream imageStream, String fileName) {
+ try {
+ BufferedImage image = ImageIO.read(imageStream);
+ if (image == null) {
+ log.error("Logo image stream produced a null image — unsupported format or empty stream [file={}]", fileName);
+ return null;
+ }
+ log.trace("Logo image read successfully [file={}, logoSize={}x{}]",
+ fileName, image.getWidth(), image.getHeight());
+ return image;
+ } catch (IOException e) {
+ log.error("Failed to read logo image stream [file={}]", fileName, e);
+ return null;
+ }
+ }
+
+ private BufferedImage scaleLogo(BufferedImage logo, int qrWidth, int qrHeight, double logoRatio) {
+ int maxSize = (int) (Math.min(qrWidth, qrHeight) * logoRatio);
+ int logoWidth = logo.getWidth();
+ int logoHeight = logo.getHeight();
+
+ log.trace("Logo scale check [original={}x{}, maxAllowed={}px, ratio={}]", logoWidth, logoHeight, maxSize, logoRatio);
+
+ if (logoWidth <= maxSize && logoHeight <= maxSize) {
+ log.debug("Logo fits within ratio, no scaling needed [logo={}x{}, max={}px]", logoWidth, logoHeight, maxSize);
+ return logo;
+ }
+
+ double scale = Math.min((double) maxSize / logoWidth, (double) maxSize / logoHeight);
+ int scaledWidth = Math.max(1, (int) (logoWidth * scale));
+ int scaledHeight = Math.max(1, (int) (logoHeight * scale));
+
+ log.debug("Scaling logo [from={}x{} to={}x{}, maxAllowed={}px, scale={}]",
+ logoWidth, logoHeight, scaledWidth, scaledHeight, maxSize, String.format("%.4f", scale));
+
+ BufferedImage scaled = new BufferedImage(scaledWidth, scaledHeight, BufferedImage.TYPE_INT_ARGB);
+ Graphics2D g = scaled.createGraphics();
+ try {
+ g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
+ g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g.drawImage(logo, 0, 0, scaledWidth, scaledHeight, null);
+ } finally {
+ g.dispose();
+ }
+
+ return scaled;
+ }
+
+ private BufferedImage compositeLogoOnQr(BufferedImage qrImage, BufferedImage logo, QrCode code) {
+ int x = (qrImage.getWidth() - logo.getWidth()) / 2;
+ int y = (qrImage.getHeight() - logo.getHeight()) / 2;
+ int padding = code.getLogoBackgroundPadding();
+ int arc = code.getLogoBackgroundArc();
+
+ log.trace("Compositing logo onto QR [qr={}x{}, logo={}x{}, offset=({},{}), padding={}, arc={}]", qrImage.getWidth(), qrImage.getHeight(), logo.getWidth(), logo.getHeight(), x, y, padding, arc);
- BufferedImage combined = new BufferedImage(qrImage.getHeight(), qrImage.getWidth(), BufferedImage.TYPE_INT_ARGB);
- Graphics2D g = (Graphics2D) combined.getGraphics();
+ BufferedImage combined = new BufferedImage(
+ qrImage.getWidth(),
+ qrImage.getHeight(),
+ BufferedImage.TYPE_INT_ARGB
+ );
+ Graphics2D g = combined.createGraphics();
+ try {
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(qrImage, 0, 0, null);
+
g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1f));
- g.drawImage(overly, Math.round(deltaWidth / 2), Math.round(deltaHeight / 2), null);
+ g.setColor(Color.WHITE);
+ g.fillRoundRect(
+ x - padding,
+ y - padding,
+ logo.getWidth() + padding * 2,
+ logo.getHeight() + padding * 2,
+ arc,
+ arc
+ );
- ImageIO.write(combined, "png", os);
- Files.copy(new ByteArrayInputStream(os.toByteArray()), Paths.get(code.getFileName()), StandardCopyOption.REPLACE_EXISTING);
+ g.drawImage(logo, x, y, null);
+ } finally {
+ g.dispose();
+ }
- return Optional.of(new File(code.getFileName()));
- } catch (WriterException | IOException e) {
- log.error("Error creating qr code.", e);
- return Optional.empty();
+ return combined;
+ }
+
+ private void writeImageToFile(BufferedImage image, QrCode code) throws IOException {
+ String format = resolveImageFormat(code);
+ Path outputPath = Path.of(code.getFileName());
+
+ log.trace("Writing combined image to disk [file={}, format={}]", code.getFileName(), format);
+
+ try (OutputStream outputStream = Files.newOutputStream(outputPath)) {
+ boolean written = ImageIO.write(image, format, outputStream);
+ if (!written) {
+ log.error("Failed to write image to disk [file={}, format={}, path={}]", code.getFileName(), format, outputPath.toAbsolutePath());
+ throw new IOException("No ImageIO writer found for format: " + format);
+ }
}
}
- private File generateFile(QrCode code) {
- return QRCode.from(code.getContent())
- .to(code.getImageType())
- .withSize(code.getWidth(), code.getHeight())
- .withColor(code.getOnColor(), code.getOffColor())
- .withErrorCorrection(code.getErrorCorrectionLevel())
- .withCharset(code.getCharset())
- .file(code.getFileName());
+
+ private void validateLogoErrorCorrection(QrCode code) {
+ if (code.getErrorCorrectionLevel() != ErrorCorrectionLevel.H) {
+ log.warn("QR code '{}' uses ErrorCorrectionLevel.{} — ErrorCorrectionLevel.H is strongly recommended when embedding a logo to ensure reliable scanning.", code.getFileName(), code.getErrorCorrectionLevel());
+ }
+ }
+
+ private String resolveImageFormat(QrCode code) {
+ if (code.getImageType() == null) {
+ log.trace("No image type set, defaulting to PNG [file={}]", code.getFileName());
+ return QrImageType.PNG.getFormat();
+ }
+ return code.getImageType().getFormat();
}
}
\ No newline at end of file