From 635f6a6e4eb2b185627654564a4aa4faa7aea42b Mon Sep 17 00:00:00 2001 From: Snake Date: Mon, 9 Mar 2026 17:01:17 -0500 Subject: [PATCH 1/2] ci: add Python test step and PR workflow --- .github/workflows/build.yml | 39 +++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a4a13f..4f05742 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,24 +4,47 @@ on: push: branches: - master + pull_request: + branches: + - master +permissions: + contents: read jobs: build: - name: Build and analyze + name: Build, test, and analyze runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v4 + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - uses: SonarSource/sonarqube-scan-action@v6 + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run unit tests + run: python -m unittest discover -s tests -v + + - name: SonarQube scan + uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - # If you wish to fail your job when the Quality Gate is red, uncomment the - # following lines. This would typically be used to fail a deployment. - # - uses: SonarSource/sonarqube-quality-gate-action@v1 + + # Enable this if you want the pipeline to fail on a red quality gate. + # - name: SonarQube quality gate + # uses: SonarSource/sonarqube-quality-gate-action@v1 # timeout-minutes: 5 # env: # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From 1a9a4b3771947584c5876bf0231ba2c0e5e1f1b2 Mon Sep 17 00:00:00 2001 From: Snake Date: Mon, 9 Mar 2026 17:47:11 -0500 Subject: [PATCH 2/2] feat: add placeholder state and logo upload customization --- app/main.py | 9 +++- app/qrcoderesponse.py | 65 ++++++++++++++++++++++++++--- app/static/css/qr.css | 63 +++++++++++++++++++++++++--- app/templates/index.html | 90 +++++++++++++++++++++++++++++++++------- 4 files changed, 197 insertions(+), 30 deletions(-) diff --git a/app/main.py b/app/main.py index 1e15f5a..994a248 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ from qrcoderesponse import qrcoderesponse MAX_QR_TEXT_LENGTH = 2048 +MAX_LOGO_BYTES = 2 * 1024 * 1024 app = Flask( __name__, @@ -10,6 +11,7 @@ template_folder="templates", ) app.config.setdefault("MAX_QR_TEXT_LENGTH", MAX_QR_TEXT_LENGTH) +app.config.setdefault("MAX_CONTENT_LENGTH", MAX_LOGO_BYTES) @app.route("/") @@ -17,7 +19,7 @@ def show_form(): return render_template("index.html") -@app.route("/qr") +@app.route("/qr", methods=["GET", "POST"]) def generate_qrcode(): text = request.values.get("text") @@ -32,7 +34,10 @@ def generate_qrcode(): if len(text) > max_length: return f"Text parameter is too long (max {max_length} characters)", 400 - return qrcoderesponse(text) + logo_file = request.files.get("logo") + logo_scale = request.values.get("logo_scale", 20) + + return qrcoderesponse(text, logo_file=logo_file, logo_scale=logo_scale) if __name__ == "__main__": diff --git a/app/qrcoderesponse.py b/app/qrcoderesponse.py index 21d28df..915faaf 100644 --- a/app/qrcoderesponse.py +++ b/app/qrcoderesponse.py @@ -1,14 +1,19 @@ import io import os +from PIL import Image, ImageOps import qrcode +from qrcode.constants import ERROR_CORRECT_H from flask import send_file -DEFAULT_PIXEL_WIDTH = 50 +DEFAULT_PIXEL_WIDTH = 12 DEFAULT_FG_COLOR = "#000000" DEFAULT_BG_COLOR = "#ffffff" MIN_PIXEL_WIDTH = 1 MAX_PIXEL_WIDTH = 100 +DEFAULT_LOGO_SCALE = 20 +MIN_LOGO_SCALE = 5 +MAX_LOGO_SCALE = 30 def _pixel_width_from_env(): @@ -29,6 +34,15 @@ def _pixel_width_from_env(): BG_COLOR = os.getenv("BG_COLOR", DEFAULT_BG_COLOR) +def _normalized_logo_scale(scale_value): + try: + scale = int(scale_value) + except (TypeError, ValueError): + return DEFAULT_LOGO_SCALE + + return max(MIN_LOGO_SCALE, min(MAX_LOGO_SCALE, scale)) + + def imagetoresponse(img): img_file = io.BytesIO() img.save(img_file, format="PNG") @@ -36,15 +50,54 @@ def imagetoresponse(img): return send_file(img_file, mimetype="image/png") -def makeqrcode(text): +def _add_center_logo(qr_image, logo_file, logo_scale): + qr_image = qr_image.convert("RGBA") + logo = Image.open(logo_file).convert("RGBA") + + qr_width, qr_height = qr_image.size + target_ratio = _normalized_logo_scale(logo_scale) / 100 + target_size = max(32, int(min(qr_width, qr_height) * target_ratio)) + + logo.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) + + padding = max(8, target_size // 8) + background = Image.new( + "RGBA", + (logo.width + (padding * 2), logo.height + (padding * 2)), + (255, 255, 255, 255), + ) + background = ImageOps.expand(background, border=0, fill=(255, 255, 255, 255)) + + pos = ( + (background.width - logo.width) // 2, + (background.height - logo.height) // 2, + ) + background.alpha_composite(logo, dest=pos) + + offset = ( + (qr_width - background.width) // 2, + (qr_height - background.height) // 2, + ) + qr_image.alpha_composite(background, dest=offset) + return qr_image.convert("RGB") + + +def makeqrcode(text, logo_file=None, logo_scale=DEFAULT_LOGO_SCALE): qr = qrcode.QRCode( - version=1, + version=None, + error_correction=ERROR_CORRECT_H, box_size=PIXEL_WIDTH, border=4, ) qr.add_data(text) - return qr.make_image(fill_color=FG_COLOR, back_color=BG_COLOR) + qr.make(fit=True) + image = qr.make_image(fill_color=FG_COLOR, back_color=BG_COLOR).convert("RGB") + + if logo_file and getattr(logo_file, "filename", ""): + image = _add_center_logo(image, logo_file, logo_scale) + + return image -def qrcoderesponse(text): - return imagetoresponse(makeqrcode(text)) +def qrcoderesponse(text, logo_file=None, logo_scale=DEFAULT_LOGO_SCALE): + return imagetoresponse(makeqrcode(text, logo_file=logo_file, logo_scale=logo_scale)) diff --git a/app/static/css/qr.css b/app/static/css/qr.css index 303aeeb..24278ee 100644 --- a/app/static/css/qr.css +++ b/app/static/css/qr.css @@ -1,17 +1,68 @@ - .headmaster { background: url('/img/bg-pattern.png'), linear-gradient(to left, #7b4397, #dc2430); - padding: 5px 0px 5px; + padding: 5px 0 5px; } .devicefull { - background-image:url('/img/iphone_6_plus_white_port.png'); + background-image: url('/img/iphone_6_plus_white_port.png'); } -.qrtexting { +.qrtexting, +.qr-input { width: 400px; - padding: 5px 15px 5px 15px; + padding: 5px 15px; margin-bottom: 15px; margin-right: 20px; margin-left: 20px; -} \ No newline at end of file +} + +.form-label { + margin-left: 20px; + color: #ffffff; + font-weight: 600; +} + +.logo-slider-wrap { + width: 400px; + margin: 0 20px 15px; + color: #ffffff; +} + +.logo-slider-wrap span { + display: inline-block; + margin-top: 6px; + font-weight: 600; +} + +.qr-screen { + display: flex; + align-items: center; + justify-content: center; + min-height: 460px; +} + +.qrcode { + max-width: 100%; + max-height: 400px; + object-fit: contain; +} + +.qr-placeholder { + width: 100%; + text-align: center; + color: #666666; +} + +.qr-placeholder-box { + width: 260px; + height: 260px; + margin: 0 auto 12px; + border-radius: 18px; + border: 2px dashed #dddddd; + background: #ffffff; +} + +.qr-placeholder p { + font-size: 1rem; + font-weight: 600; +} diff --git a/app/templates/index.html b/app/templates/index.html index f15e4c6..f5bad40 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -16,14 +16,20 @@
-
+
-
- + + + - + + + + +
+ + 20% +
@@ -32,7 +38,13 @@
-
Here's your QRCode!
+
+
+
+

Enter text to generate QR

+
+ Here's your QRCode! +
@@ -54,14 +66,60 @@ + + async function fetchcode() { + const text = qrtxt.value.trim(); + logoScaleValue.textContent = `${logoScale.value}%`; + + if (!text) { + showPlaceholder(); + return; + } + + const formData = new FormData(); + formData.append('text', text); + formData.append('logo_scale', logoScale.value); + if (logoInput.files[0]) { + formData.append('logo', logoInput.files[0]); + } + + try { + const response = await fetch('/qr', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + showPlaceholder(); + return; + } + + const blob = await response.blob(); + const imageUrl = URL.createObjectURL(blob); + qrimg.src = imageUrl; + qrimg.classList.remove('d-none'); + placeholder.classList.add('d-none'); + } catch (error) { + showPlaceholder(); + } + } + + qrtxt.addEventListener('input', fetchcode); + logoInput.addEventListener('change', fetchcode); + logoScale.addEventListener('input', fetchcode); + showPlaceholder(); + - \ No newline at end of file +