From 05243a06812f77a2490bfa96471c9bdcb167acfc Mon Sep 17 00:00:00 2001 From: Jonathan2745 Date: Sun, 5 Jul 2026 16:06:26 +0800 Subject: [PATCH 1/4] README and project setup --- README.md | 317 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 316 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7577c27..b03c627 100644 --- a/README.md +++ b/README.md @@ -1 +1,316 @@ -#QR Code Generator App +# QR Code Generator App + +A desktop QR code generator built with **Python 3.14**, **PySide6**, `qrcode`, and Pillow. + +This app provides a simple GUI for generating QR codes from user-provided text, URLs, contact details, payment strings, or other QR-compatible content. + +## Features + +* Generate QR codes from custom text input +* Save generated QR codes as image files +* Desktop GUI built with PySide6 / Qt +* Configurable default output path and QR styling through `.env` +* Clean Python project structure using `pyproject.toml` +* Development tools included for testing, linting, and formatting + +## Tech Stack + +* Python 3.14 +* PySide6 +* qrcode +* Pillow +* python-dotenv +* pytest +* pytest-qt +* ruff + +## Project Structure + +```text +QRCodeGeneratorApp/ +├── .env +├── .env.example +├── .gitignore +├── pyproject.toml +├── README.md +├── src/ +│ └── qrgenerator/ +│ ├── __init__.py +│ ├── main.py +│ ├── main_window.py +│ ├── config.py +│ └── qr_service.py +└── tests/ + └── test_qr_service.py +``` + +## Requirements + +* Python 3.14 +* pip +* Windows, macOS, or Linux + +This project is currently developed using a local Python virtual environment. + +## Setup + +### 1. Clone or open the project folder + +```powershell +cd "C:\Users\Eisen\Documents\QRCodeGeneratorApp" +``` + +For macOS or Linux: + +```bash +cd "/path/to/QRCodeGeneratorApp" +``` + +### 2. Create a virtual environment + +On Windows: + +```powershell +py -3.14 -m venv .venv +.\.venv\Scripts\Activate.ps1 +``` + +On macOS or Linux: + +```bash +python3.14 -m venv .venv +source .venv/bin/activate +``` + +After activation, the terminal should show `(.venv)`. + +### 3. Upgrade pip + +```bash +python -m pip install --upgrade pip +``` + +### 4. Install the project dependencies + +Install the app in editable development mode: + +```bash +pip install -e ".[dev]" +``` + +This installs the main app dependencies and development tools. + +Main dependencies: + +```text +PySide6 +qrcode[pil] +Pillow +python-dotenv +``` + +Development dependencies: + +```text +pytest +pytest-qt +ruff +``` + +## Environment Configuration + +Create a `.env` file in the project root. + +Example: + +```env +APP_NAME=QR Generator +DEFAULT_OUTPUT_DIR=output +DEFAULT_QR_FILENAME=qr_code.png + +QR_BOX_SIZE=10 +QR_BORDER=4 +QR_FILL_COLOR=black +QR_BACK_COLOR=white + +LOG_LEVEL=INFO +``` + +A `.env.example` file should also be included in the repository so other users know which settings are required. + +The `.env` file is used for local configuration and should not be committed to Git. + +## Running the App + +After installing the project, run: + +```bash +qrgenerator +``` + +Alternatively, run the module directly: + +```bash +python -m qrgenerator.main +``` + +## Basic Usage + +1. Open the app. +2. Enter the content you want to encode into a QR code. +3. Click the generate button. +4. The generated QR code will be saved to the configured output directory. + +By default, the output file is saved as: + +```text +output/qr_code.png +``` + +## Testing Imports + +To verify that all required packages are installed correctly: + +```bash +python -c "import qrcode; from PIL import Image; from PySide6 import QtWidgets; print('All imports OK')" +``` + +Expected output: + +```text +All imports OK +``` + +## Running Tests + +Run all tests: + +```bash +pytest +``` + +Example test coverage should include: + +* QR code generation +* Output file creation +* Empty input validation +* Configuration loading + +## Formatting and Linting + +Format the code: + +```bash +ruff format . +``` + +Check for linting issues: + +```bash +ruff check . +``` + +Fix automatically fixable linting issues: + +```bash +ruff check . --fix +``` + +## Packaging the App + +For Windows, the app can be packaged into an executable using PyInstaller. + +Install PyInstaller: + +```bash +pip install pyinstaller +``` + +Build the app: + +```bash +pyinstaller --name QRGenerator --windowed src\qrgenerator\main.py +``` + +The output will be created in: + +```text +dist/QRGenerator/ +``` + +For macOS, the app can be packaged later as a `.app` bundle. + +## Common Issues + +### `ModuleNotFoundError: No module named 'qrcode'` + +Make sure your virtual environment is activated, then reinstall dependencies: + +```bash +pip install -e ".[dev]" +``` + +### `ModuleNotFoundError: No module named 'PIL'` + +Do not install `pil`. + +Use Pillow instead: + +```bash +pip install Pillow +``` + +Or reinstall all project dependencies: + +```bash +pip install -e ".[dev]" +``` + +### PowerShell blocks virtual environment activation + +If Windows PowerShell prevents activation, run: + +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +``` + +Then activate again: + +```powershell +.\.venv\Scripts\Activate.ps1 +``` + +### Hatch cannot find the package folder + +If `pip install -e ".[dev]"` fails with an error saying it cannot determine which files to ship, make sure the project contains: + +```text +src/qrgenerator/__init__.py +``` + +Also ensure `pyproject.toml` includes: + +```toml +[tool.hatch.build.targets.wheel] +packages = ["src/qrgenerator"] +``` + +## Development Notes + +The app should keep GUI code separate from QR generation logic. + +Recommended file responsibilities: + +```text +main.py Starts the QApplication +main_window.py Contains the PySide6 window and widgets +qr_service.py Handles QR code generation +config.py Loads .env configuration +tests/ Contains automated tests +``` + +Avoid placing all code in one file once the app grows. This makes the app easier to test, package, and maintain. + +## License + +This project is for internal or personal use unless a license is added. From 9a90fe4a3f4cd8441f6f34331aeb53ec0109eab0 Mon Sep 17 00:00:00 2001 From: Jonathan2745 Date: Sun, 5 Jul 2026 16:33:06 +0800 Subject: [PATCH 2/4] feat(app): created basic framework --- .gitignore | 15 +++ pyproject.toml | 36 +++++++ src/qrgenerator/__init__.py | 0 src/qrgenerator/config.py | 33 +++++++ src/qrgenerator/main.py | 0 src/qrgenerator/main_window.py | 0 src/qrgenerator/qr_service.py | 65 +++++++++++++ src/qrgenerator/vcard_service.py | 162 +++++++++++++++++++++++++++++++ 8 files changed, 311 insertions(+) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/qrgenerator/__init__.py create mode 100644 src/qrgenerator/config.py create mode 100644 src/qrgenerator/main.py create mode 100644 src/qrgenerator/main_window.py create mode 100644 src/qrgenerator/qr_service.py create mode 100644 src/qrgenerator/vcard_service.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41ed0e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.venv/ +__pycache__/ +*.pyc + +.env +output/ +dist/ +build/ +*.egg-info/ + +.DS_Store +Thumbs.db + +.pytest_cache/ +.ruff_cache/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1226ff0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "qrgenerator" +version = "0.1.0" +description = "A PySide6 desktop QR code generator" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "PySide6>=6.7", + "qrcode[pil]>=8.0", + "Pillow>=10.0", + "python-dotenv>=1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-qt>=4.4", + "ruff>=0.6", +] + +[project.scripts] +qrgenerator = "qrgenerator.main:main" + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "B"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + diff --git a/src/qrgenerator/__init__.py b/src/qrgenerator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/qrgenerator/config.py b/src/qrgenerator/config.py new file mode 100644 index 0000000..4fb7842 --- /dev/null +++ b/src/qrgenerator/config.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from dotenv import load_dotenv + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + +load_dotenv(PROJECT_ROOT / ".env") + + +def _get_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + + if value is None: + return default + + return value.lower() in {"1", "true", "yes", "y", "on"} + + +APP_NAME = os.getenv("APP_NAME", "QR Code Generator") + +DEFAULT_OUTPUT_DIR = PROJECT_ROOT / os.getenv("DEFAULT_OUTPUT_DIR", "output") + +QR_BOX_SIZE = int(os.getenv("QR_BOX_SIZE", "10")) +QR_BORDER = int(os.getenv("QR_BORDER", "0")) +QR_FILL_COLOR = os.getenv("QR_FILL_COLOR", "black") +QR_BACK_COLOR = os.getenv("QR_BACK_COLOR", "white") +QR_TRANSPARENT_BACKGROUND = _get_bool("QR_TRANSPARENT_BACKGROUND", True) + +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") \ No newline at end of file diff --git a/src/qrgenerator/main.py b/src/qrgenerator/main.py new file mode 100644 index 0000000..e69de29 diff --git a/src/qrgenerator/main_window.py b/src/qrgenerator/main_window.py new file mode 100644 index 0000000..e69de29 diff --git a/src/qrgenerator/qr_service.py b/src/qrgenerator/qr_service.py new file mode 100644 index 0000000..8485526 --- /dev/null +++ b/src/qrgenerator/qr_service.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path + +import qrcode +from PIL import Image + +from qrgenerator.config import ( + QR_BACK_COLOR, + QR_BORDER, + QR_BOX_SIZE, + QR_FILL_COLOR, + QR_TRANSPARENT_BACKGROUND, +) + + +def make_background_transparent(image: Image.Image) -> Image.Image: + """ + Convert white pixels to transparent pixels. + """ + rgba_image = image.convert("RGBA") + + pixels = rgba_image.getdata() + new_pixels = [] + + for r, g, b, a in pixels: + if (r, g, b) == (255, 255, 255): + new_pixels.append((255, 255, 255, 0)) + else: + new_pixels.append((0, 0, 0, 255)) + + rgba_image.putdata(new_pixels) + return rgba_image + + +def generate_qr_code(data: str, output_path: Path) -> Path: + """ + Generate a QR code image and save it to output_path. + """ + if not data.strip(): + raise ValueError("QR code data cannot be empty.") + + output_path.parent.mkdir(parents=True, exist_ok=True) + + qr = qrcode.QRCode( + version=None, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=QR_BOX_SIZE, + border=QR_BORDER, + ) + + qr.add_data(data) + qr.make(fit=True) + + image = qr.make_image( + fill_color=QR_FILL_COLOR, + back_color=QR_BACK_COLOR, + ).convert("RGBA") + + if QR_TRANSPARENT_BACKGROUND: + image = make_background_transparent(image) + + image.save(output_path) + + return output_path \ No newline at end of file diff --git a/src/qrgenerator/vcard_service.py b/src/qrgenerator/vcard_service.py new file mode 100644 index 0000000..f6b2a88 --- /dev/null +++ b/src/qrgenerator/vcard_service.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +def escape_vcard_text(value: str) -> str: + """ + Escape special characters for vCard text fields. + """ + return ( + value.strip() + .replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\n", "\\n") + .replace("\r", "") + ) + + +def safe_filename(value: str) -> str: + """ + Convert a contact name into a safe filename. + """ + cleaned = value.strip() or "contact" + cleaned = re.sub(r'[<>:"/\\|?*]', "_", cleaned) + cleaned = re.sub(r"\s+", " ", cleaned) + return cleaned + + +@dataclass +class Address: + label: str = "work" + first_line: str = "" + second_line: str = "" + state: str = "" + county: str = "" + postal_code: str = "" + country: str = "" + + def is_empty(self) -> bool: + return not any( + [ + self.first_line.strip(), + self.second_line.strip(), + self.state.strip(), + self.county.strip(), + self.postal_code.strip(), + self.country.strip(), + ] + ) + + +@dataclass +class PhoneNumber: + label: str = "work" + country_code: str = "" + number: str = "" + preferred: bool = False + + def formatted_phone(self) -> str: + return f"{self.country_code.strip()} {self.number.strip()}".strip() + + def is_empty(self) -> bool: + return not self.formatted_phone() + + +@dataclass +class VCardData: + first_name: str = "" + last_name: str = "" + title: str = "" + organisation: str = "" + email: str = "" + url: str = "" + + primary_phone: PhoneNumber = field(default_factory=PhoneNumber) + extra_phones: list[PhoneNumber] = field(default_factory=list) + addresses: list[Address] = field(default_factory=list) + + @property + def full_name(self) -> str: + return f"{self.first_name.strip()} {self.last_name.strip()}".strip() + + +def build_vcard(data: VCardData) -> str: + """ + Build vCard 3.0 text using CRLF line endings. + """ + full_name = data.full_name or "Unnamed Contact" + + lines = [ + "BEGIN:VCARD", + "VERSION:3.0", + f"N:{escape_vcard_text(data.last_name)};{escape_vcard_text(data.first_name)};;;", + f"FN:{escape_vcard_text(full_name)}", + ] + + if data.title.strip(): + lines.append(f"TITLE:{escape_vcard_text(data.title)}") + + if data.organisation.strip(): + lines.append(f"ORG:{escape_vcard_text(data.organisation)}") + + if data.email.strip(): + lines.append(f"EMAIL;TYPE=work;PREF:{escape_vcard_text(data.email)}") + + if not data.primary_phone.is_empty(): + phone_type = data.primary_phone.label or "work" + lines.append( + f"TEL;TYPE={escape_vcard_text(phone_type)},voice,pref:" + f"{escape_vcard_text(data.primary_phone.formatted_phone())}" + ) + + for phone in data.extra_phones: + if phone.is_empty(): + continue + + phone_type = phone.label or "work" + + if phone.preferred: + lines.append( + f"TEL;TYPE={escape_vcard_text(phone_type)},voice,pref:" + f"{escape_vcard_text(phone.formatted_phone())}" + ) + else: + lines.append( + f"TEL;TYPE={escape_vcard_text(phone_type)},voice:" + f"{escape_vcard_text(phone.formatted_phone())}" + ) + + for address in data.addresses: + if address.is_empty(): + continue + + address_type = address.label or "work" + + street = " ".join( + part.strip() + for part in [address.first_line, address.second_line] + if part.strip() + ) + + lines.append( + f"ADR;TYPE={escape_vcard_text(address_type)}:" + f";;" + f"{escape_vcard_text(street)};" + f"{escape_vcard_text(address.county)};" + f"{escape_vcard_text(address.state)};" + f"{escape_vcard_text(address.postal_code)};" + f"{escape_vcard_text(address.country)}" + ) + + if data.url.strip(): + lines.append(f"URL;TYPE=work;PREF:{escape_vcard_text(data.url)}") + + revision_time = datetime.now(timezone.utc).isoformat(timespec="milliseconds") + lines.append(f"REV:{revision_time}") + lines.append("END:VCARD") + + return "\r\n".join(lines) + "\r\n" \ No newline at end of file From d88774c5b94c882467886e33cbc6c1cd6789a02b Mon Sep 17 00:00:00 2001 From: Jonathan2745 Date: Sun, 5 Jul 2026 18:23:47 +0800 Subject: [PATCH 3/4] draft_release_01 --- .env.example | 10 + .github/workflows/ci.yml | 44 +++ QRGenerator.spec | 44 +++ src/qrgenerator/config.py | 3 +- src/qrgenerator/main.py | 20 ++ src/qrgenerator/main_window.py | 591 +++++++++++++++++++++++++++++++ src/qrgenerator/qr_service.py | 4 +- src/qrgenerator/vcard_service.py | 34 +- tests/test_main_window.py | 126 +++++++ tests/test_qr_service.py | 43 +++ tests/test_vcard_service.py | 88 +++++ 11 files changed, 996 insertions(+), 11 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 QRGenerator.spec create mode 100644 tests/test_main_window.py create mode 100644 tests/test_qr_service.py create mode 100644 tests/test_vcard_service.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..106f8e7 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +APP_NAME=QR Code Generator +DEFAULT_OUTPUT_DIR=output + +QR_BOX_SIZE=10 +QR_BORDER=0 +QR_FILL_COLOR=black +QR_BACK_COLOR=white +QR_TRANSPARENT_BACKGROUND=true + +LOG_LEVEL=INFO diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f5e2d40 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + +jobs: + test: + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: ["ubuntu-latest", "windows-latest"] + python-version: ["3.11", "3.14"] + + env: + QT_QPA_PLATFORM: offscreen + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Check formatting + run: python -m ruff format --check . + + - name: Lint + run: python -m ruff check . + + - name: Run tests + run: python -m pytest diff --git a/QRGenerator.spec b/QRGenerator.spec new file mode 100644 index 0000000..d1a739b --- /dev/null +++ b/QRGenerator.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['src\\qrgenerator\\main.py'], + pathex=['src'], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='QRGenerator', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='QRGenerator', +) diff --git a/src/qrgenerator/config.py b/src/qrgenerator/config.py index 4fb7842..640936f 100644 --- a/src/qrgenerator/config.py +++ b/src/qrgenerator/config.py @@ -5,7 +5,6 @@ from dotenv import load_dotenv - PROJECT_ROOT = Path(__file__).resolve().parents[2] load_dotenv(PROJECT_ROOT / ".env") @@ -30,4 +29,4 @@ def _get_bool(name: str, default: bool = False) -> bool: QR_BACK_COLOR = os.getenv("QR_BACK_COLOR", "white") QR_TRANSPARENT_BACKGROUND = _get_bool("QR_TRANSPARENT_BACKGROUND", True) -LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") \ No newline at end of file +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") diff --git a/src/qrgenerator/main.py b/src/qrgenerator/main.py index e69de29..36a78be 100644 --- a/src/qrgenerator/main.py +++ b/src/qrgenerator/main.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import sys + +from PySide6.QtWidgets import QApplication + +from qrgenerator.main_window import MainWindow + + +def main() -> int: + app = QApplication.instance() or QApplication(sys.argv) + + window = MainWindow() + window.show() + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/qrgenerator/main_window.py b/src/qrgenerator/main_window.py index e69de29..779d74c 100644 --- a/src/qrgenerator/main_window.py +++ b/src/qrgenerator/main_window.py @@ -0,0 +1,591 @@ +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap, QResizeEvent +from PySide6.QtWidgets import ( + QCheckBox, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QPlainTextEdit, + QPushButton, + QScrollArea, + QSpinBox, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from qrgenerator.config import APP_NAME, DEFAULT_OUTPUT_DIR +from qrgenerator.qr_service import generate_qr_code +from qrgenerator.vcard_service import Address, PhoneNumber, VCardData, build_vcard + + +class MainWindow(QMainWindow): + """ + Main application window for generating QR codes from user-entered content. + """ + + def __init__( + self, + default_output_path: Path | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + + self.current_preview_path: Path | None = None + + output_path = default_output_path or DEFAULT_OUTPUT_DIR / "qr_code.png" + + self.setWindowTitle(APP_NAME) + self.resize(960, 720) + + self.input_tabs = QTabWidget() + self.input_tabs.setObjectName("inputTabs") + + self.text_tab = QWidget() + self.contact_tab = QWidget() + + self._setup_text_tab() + self._setup_contact_tab() + + self.input_tabs.addTab(self.text_tab, "Text") + self.input_tabs.addTab(self.contact_tab, "Contact") + self.input_tabs.setCurrentWidget(self.contact_tab) + + self.output_path_edit = QLineEdit(str(output_path)) + self.output_path_edit.setObjectName("outputPathEdit") + + self.browse_button = QPushButton("Browse...") + self.browse_button.setObjectName("browseButton") + + output_layout = QHBoxLayout() + output_layout.addWidget(self.output_path_edit, 1) + output_layout.addWidget(self.browse_button) + + form_layout = QFormLayout() + form_layout.addRow("Save as", output_layout) + + self.preview_label = QLabel("No QR code generated") + self.preview_label.setObjectName("previewLabel") + self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_label.setMinimumSize(260, 260) + self.preview_label.setStyleSheet( + "QLabel { border: 1px solid #c8c8c8; background: #fafafa; color: #555; }" + ) + + self.generate_button = QPushButton("Generate QR Code") + self.generate_button.setObjectName("generateButton") + + self.status_label = QLabel("Ready") + self.status_label.setObjectName("statusLabel") + self.status_label.setWordWrap(True) + + layout = QVBoxLayout() + layout.addWidget(self.input_tabs) + layout.addLayout(form_layout) + layout.addWidget(self.preview_label, 1) + layout.addWidget(self.generate_button) + layout.addWidget(self.status_label) + + central_widget = QWidget() + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) + + self.output_path_edit.textChanged.connect(self._sync_generate_button) + self.input_tabs.currentChanged.connect(self._sync_generate_button) + self.browse_button.clicked.connect(self.choose_output_path) + self.generate_button.clicked.connect(self.generate_qr) + + self._sync_generate_button() + + def _setup_text_tab(self) -> None: + self.content_editor = QPlainTextEdit() + self.content_editor.setObjectName("contentEditor") + self.content_editor.setPlaceholderText("Enter text, URL, or contact details") + self.content_editor.setMinimumHeight(140) + self.content_editor.textChanged.connect(self._sync_generate_button) + + layout = QVBoxLayout() + layout.addWidget(self.content_editor) + self.text_tab.setLayout(layout) + + def _setup_contact_tab(self) -> None: + self.first_name_edit = QLineEdit() + self.first_name_edit.setObjectName("firstNameEdit") + + self.last_name_edit = QLineEdit() + self.last_name_edit.setObjectName("lastNameEdit") + + self.title_edit = QLineEdit() + self.title_edit.setObjectName("titleEdit") + + self.organisation_edit = QLineEdit() + self.organisation_edit.setObjectName("organisationEdit") + + details_form = QFormLayout() + details_form.addRow("First name", self.first_name_edit) + details_form.addRow("Last name", self.last_name_edit) + details_form.addRow("Title", self.title_edit) + details_form.addRow("Organisation", self.organisation_edit) + + self.phone_count_spin = self._create_count_spin_box("numberOfContactNumberSpin", 1) + self.email_count_spin = self._create_count_spin_box("numberOfEmailSpin", 1) + self.url_count_spin = self._create_count_spin_box("numberOfUrlSpin", 1) + self.address_count_spin = self._create_count_spin_box("numberOfAddressSpin", 1) + + self.phone_fields_layout = QVBoxLayout() + self.email_fields_layout = QVBoxLayout() + self.url_fields_layout = QVBoxLayout() + self.address_fields_layout = QVBoxLayout() + self.address_fields_layout.setSpacing(12) + + details_group = self._create_section("Identity", details_form) + phone_group = self._create_repeating_section( + "Phone numbers", + "Number of contact numbers", + self.phone_count_spin, + self.phone_fields_layout, + ) + email_group = self._create_repeating_section( + "Emails", + "Number of emails", + self.email_count_spin, + self.email_fields_layout, + ) + url_group = self._create_repeating_section( + "URLs", + "Number of URLs", + self.url_count_spin, + self.url_fields_layout, + ) + address_group = self._create_repeating_section( + "Addresses", + "Number of addresses", + self.address_count_spin, + self.address_fields_layout, + ) + + for field in [ + self.first_name_edit, + self.last_name_edit, + self.title_edit, + self.organisation_edit, + ]: + field.textChanged.connect(self._sync_generate_button) + + self.phone_fields: list[tuple[QLineEdit, QLineEdit]] = [] + self.email_fields: list[QLineEdit] = [] + self.url_fields: list[tuple[QLineEdit, QLineEdit]] = [] + self.address_fields: list[dict[str, QLineEdit | QCheckBox]] = [] + + self.phone_count_spin.valueChanged.connect(self._rebuild_phone_fields) + self.email_count_spin.valueChanged.connect(self._rebuild_email_fields) + self.url_count_spin.valueChanged.connect(self._rebuild_url_fields) + self.address_count_spin.valueChanged.connect(self._rebuild_address_fields) + + self._rebuild_phone_fields() + self._rebuild_email_fields() + self._rebuild_url_fields() + self._rebuild_address_fields() + + contact_layout = QVBoxLayout() + contact_layout.setSpacing(14) + contact_layout.addWidget(details_group) + contact_layout.addWidget(phone_group) + contact_layout.addWidget(email_group) + contact_layout.addWidget(url_group) + contact_layout.addWidget(address_group) + contact_layout.addStretch(1) + + contact_content = QWidget() + contact_content.setLayout(contact_layout) + + scroll_area = QScrollArea() + scroll_area.setMinimumHeight(420) + scroll_area.setWidgetResizable(True) + scroll_area.setWidget(contact_content) + + layout = QVBoxLayout() + layout.addWidget(scroll_area) + self.contact_tab.setLayout(layout) + + def _create_section(self, title: str, layout: QFormLayout | QVBoxLayout) -> QGroupBox: + group = QGroupBox(title) + group.setLayout(layout) + return group + + def _create_repeating_section( + self, + title: str, + count_label: str, + count_spin: QSpinBox, + fields_layout: QVBoxLayout, + ) -> QGroupBox: + count_layout = QFormLayout() + count_layout.addRow(count_label, count_spin) + + section_layout = QVBoxLayout() + section_layout.setSpacing(10) + section_layout.addLayout(count_layout) + section_layout.addLayout(fields_layout) + + return self._create_section(title, section_layout) + + def _create_count_spin_box(self, object_name: str, value: int) -> QSpinBox: + spin_box = QSpinBox() + spin_box.setObjectName(object_name) + spin_box.setRange(0, 10) + spin_box.setValue(value) + return spin_box + + def choose_output_path(self) -> None: + selected_path, _selected_filter = QFileDialog.getSaveFileName( + self, + "Save QR code", + self.output_path_edit.text(), + "PNG Images (*.png);;All Files (*)", + ) + + if selected_path: + if not selected_path.lower().endswith(".png"): + selected_path = f"{selected_path}.png" + self.output_path_edit.setText(selected_path) + + def generate_qr(self) -> None: + data = self._build_qr_payload() + output_path_text = self.output_path_edit.text().strip() + + if not data.strip(): + if self._is_contact_mode(): + self.status_label.setText("Enter contact details before generating a QR code.") + else: + self.status_label.setText("Enter content before generating a QR code.") + return + + if not output_path_text: + self.status_label.setText("Choose where to save the QR code.") + return + + output_path = Path(output_path_text).expanduser() + + try: + saved_path = generate_qr_code(data, output_path) + except Exception as exc: + self.status_label.setText(f"Could not generate QR code: {exc}") + return + + self.current_preview_path = saved_path + self._update_preview() + self.status_label.setText(f"Saved QR code to {saved_path}") + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self._update_preview() + + def _sync_generate_button(self) -> None: + if not hasattr(self, "generate_button"): + return + + has_content = ( + self._has_contact_payload() if self._is_contact_mode() else self._has_text_payload() + ) + has_output_path = bool(self.output_path_edit.text().strip()) + self.generate_button.setEnabled(has_content and has_output_path) + + def _is_contact_mode(self) -> bool: + return self.input_tabs.currentWidget() is self.contact_tab + + def _has_text_payload(self) -> bool: + return bool(self.content_editor.toPlainText().strip()) + + def _has_contact_payload(self) -> bool: + return any( + [ + self.first_name_edit.text().strip(), + self.last_name_edit.text().strip(), + self.title_edit.text().strip(), + self.organisation_edit.text().strip(), + *(number_edit.text().strip() for _type_edit, number_edit in self.phone_fields), + *(email_edit.text().strip() for email_edit in self.email_fields), + *(url_edit.text().strip() for _type_edit, url_edit in self.url_fields), + *( + field.text().strip() + for address in self.address_fields + for key, field in address.items() + if key != "include_county" and isinstance(field, QLineEdit) + ), + ] + ) + + def _build_qr_payload(self) -> str: + if self._is_contact_mode(): + return self._build_vcard_payload() + + return self.content_editor.toPlainText() + + def _build_vcard_payload(self) -> str: + phone_numbers = [ + PhoneNumber( + label=phone_type_edit.text().strip() or "work", + number=number_edit.text().strip(), + preferred=index == 0, + ) + for index, (phone_type_edit, number_edit) in enumerate(self.phone_fields) + if number_edit.text().strip() + ] + + emails = [ + email_edit.text().strip() + for email_edit in self.email_fields + if email_edit.text().strip() + ] + urls = [ + url_edit.text().strip() + for _type_edit, url_edit in self.url_fields + if url_edit.text().strip() + ] + fallback_url_type = self.organisation_edit.text().strip() or "work" + url_types = [ + type_edit.text().strip() or fallback_url_type + for type_edit, url_edit in self.url_fields + if url_edit.text().strip() + ] + + addresses = [] + for address in self.address_fields: + include_county = address["include_county"] + county = ( + address["county"].text().strip() + if isinstance(include_county, QCheckBox) and include_county.isChecked() + else "" + ) + + addresses.append( + Address( + first_line=address["line_1"].text().strip(), + second_line=address["line_2"].text().strip(), + country=address["country"].text().strip(), + state=address["state"].text().strip(), + county=county, + postal_code=address["postal_code"].text().strip(), + ) + ) + + primary_phone = phone_numbers[0] if phone_numbers else PhoneNumber() + extra_phones = phone_numbers[1:] + + return build_vcard( + VCardData( + first_name=self.first_name_edit.text().strip(), + last_name=self.last_name_edit.text().strip(), + title=self.title_edit.text().strip(), + organisation=self.organisation_edit.text().strip(), + emails=emails, + urls=urls, + url_type=fallback_url_type, + url_types=url_types, + primary_phone=primary_phone, + extra_phones=extra_phones, + addresses=addresses, + ) + ) + + def _rebuild_phone_fields(self) -> None: + existing_values = [ + (type_edit.text(), number_edit.text()) for type_edit, number_edit in self.phone_fields + ] + self._clear_layout(self.phone_fields_layout) + self.phone_fields = [] + + for index in range(self.phone_count_spin.value()): + phone_type_edit = QLineEdit() + phone_type_edit.setObjectName(f"phoneTypeEdit_{index + 1}") + phone_type_edit.setPlaceholderText("mobile, work, home") + + number_edit = QLineEdit() + number_edit.setObjectName(f"contactNumberEdit_{index + 1}") + number_edit.setPlaceholderText("+65 1234 5678") + + if index < len(existing_values): + phone_type_edit.setText(existing_values[index][0]) + number_edit.setText(existing_values[index][1]) + + row_layout = QFormLayout() + row_layout.addRow(f"phone_type_{index + 1}", phone_type_edit) + row_layout.addRow(f"contact_number_{index + 1}", number_edit) + self.phone_fields_layout.addLayout(row_layout) + self.phone_fields.append((phone_type_edit, number_edit)) + + phone_type_edit.textChanged.connect(self._sync_generate_button) + number_edit.textChanged.connect(self._sync_generate_button) + + self._sync_generate_button() + + def _rebuild_email_fields(self) -> None: + existing_values = [email_edit.text() for email_edit in self.email_fields] + self._clear_layout(self.email_fields_layout) + self.email_fields = [] + + for index in range(self.email_count_spin.value()): + email_edit = QLineEdit() + email_edit.setObjectName(f"emailEdit_{index + 1}") + + if index < len(existing_values): + email_edit.setText(existing_values[index]) + + self.email_fields_layout.addWidget(QLabel(f"email_{index + 1}")) + self.email_fields_layout.addWidget(email_edit) + self.email_fields.append(email_edit) + email_edit.textChanged.connect(self._sync_generate_button) + + self._sync_generate_button() + + def _rebuild_url_fields(self) -> None: + existing_values = [ + (type_edit.text(), url_edit.text()) for type_edit, url_edit in self.url_fields + ] + self._clear_layout(self.url_fields_layout) + self.url_fields = [] + + for index in range(self.url_count_spin.value()): + url_type_edit = QLineEdit() + url_type_edit.setObjectName(f"urlTypeEdit_{index + 1}") + url_type_edit.setPlaceholderText("Defaults to organisation") + + url_edit = QLineEdit() + url_edit.setObjectName(f"urlEdit_{index + 1}") + + if index < len(existing_values): + url_type_edit.setText(existing_values[index][0]) + url_edit.setText(existing_values[index][1]) + + row_layout = QFormLayout() + row_layout.addRow(f"url_type_{index + 1}", url_type_edit) + row_layout.addRow(f"url_{index + 1}", url_edit) + self.url_fields_layout.addLayout(row_layout) + self.url_fields.append((url_type_edit, url_edit)) + url_type_edit.textChanged.connect(self._sync_generate_button) + url_edit.textChanged.connect(self._sync_generate_button) + + self._sync_generate_button() + + def _rebuild_address_fields(self) -> None: + existing_values = [ + { + "line_1": address["line_1"].text(), + "line_2": address["line_2"].text(), + "country": address["country"].text(), + "state": address["state"].text(), + "county": address["county"].text(), + "postal_code": address["postal_code"].text(), + "include_county": address["include_county"].isChecked(), + } + for address in self.address_fields + ] + self._clear_layout(self.address_fields_layout) + self.address_fields = [] + + for index in range(self.address_count_spin.value()): + fields = { + "line_1": QLineEdit(), + "line_2": QLineEdit(), + "country": QLineEdit(), + "state": QLineEdit(), + "county": QLineEdit(), + "postal_code": QLineEdit(), + "include_county": QCheckBox("Include county"), + } + + for key, field in fields.items(): + if isinstance(field, QLineEdit): + field.setObjectName(f"address{self._field_object_suffix(key)}Edit_{index + 1}") + else: + field.setObjectName(f"addressIncludeCountyCheck_{index + 1}") + + if index < len(existing_values): + for key, value in existing_values[index].items(): + field = fields[key] + if isinstance(field, QLineEdit): + field.setText(str(value)) + elif isinstance(field, QCheckBox): + field.setChecked(bool(value)) + + county_edit = fields["county"] + include_county_check = fields["include_county"] + + if isinstance(county_edit, QLineEdit) and isinstance(include_county_check, QCheckBox): + county_edit.setEnabled(include_county_check.isChecked()) + include_county_check.toggled.connect(county_edit.setEnabled) + include_county_check.toggled.connect(self._sync_generate_button) + + row_layout = QFormLayout() + row_layout.addRow(f"address_line_1_{index + 1}", fields["line_1"]) + row_layout.addRow(f"address_line_2_{index + 1}", fields["line_2"]) + row_layout.addRow(f"address_Country_{index + 1}", fields["country"]) + row_layout.addRow(f"address_state_{index + 1}", fields["state"]) + row_layout.addRow("", fields["include_county"]) + row_layout.addRow(f"address_county_{index + 1}", fields["county"]) + row_layout.addRow(f"address_postal_code_{index + 1}", fields["postal_code"]) + + address_group = QGroupBox(f"Address {index + 1}") + address_group.setLayout(row_layout) + self.address_fields_layout.addWidget(address_group) + + for field in fields.values(): + if isinstance(field, QLineEdit): + field.textChanged.connect(self._sync_generate_button) + + self.address_fields.append(fields) + + self._sync_generate_button() + + def _field_object_suffix(self, key: str) -> str: + return "".join(part.capitalize() for part in key.split("_")) + + def _clear_layout(self, layout: QVBoxLayout) -> None: + while layout.count(): + item = layout.takeAt(0) + child_layout = item.layout() + widget = item.widget() + + if child_layout is not None: + self._clear_nested_layout(child_layout) + + if widget is not None: + widget.deleteLater() + + def _clear_nested_layout(self, layout: QFormLayout | QHBoxLayout | QVBoxLayout) -> None: + while layout.count(): + item = layout.takeAt(0) + child_layout = item.layout() + widget = item.widget() + + if child_layout is not None: + self._clear_nested_layout(child_layout) + + if widget is not None: + widget.deleteLater() + + def _update_preview(self) -> None: + if self.current_preview_path is None: + return + + pixmap = QPixmap(str(self.current_preview_path)) + + if pixmap.isNull(): + self.preview_label.setText("Preview unavailable") + return + + scaled_pixmap = pixmap.scaled( + self.preview_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self.preview_label.setPixmap(scaled_pixmap) diff --git a/src/qrgenerator/qr_service.py b/src/qrgenerator/qr_service.py index 8485526..64f4f5e 100644 --- a/src/qrgenerator/qr_service.py +++ b/src/qrgenerator/qr_service.py @@ -23,7 +23,7 @@ def make_background_transparent(image: Image.Image) -> Image.Image: pixels = rgba_image.getdata() new_pixels = [] - for r, g, b, a in pixels: + for r, g, b, _a in pixels: if (r, g, b) == (255, 255, 255): new_pixels.append((255, 255, 255, 0)) else: @@ -62,4 +62,4 @@ def generate_qr_code(data: str, output_path: Path) -> Path: image.save(output_path) - return output_path \ No newline at end of file + return output_path diff --git a/src/qrgenerator/vcard_service.py b/src/qrgenerator/vcard_service.py index f6b2a88..02c8901 100644 --- a/src/qrgenerator/vcard_service.py +++ b/src/qrgenerator/vcard_service.py @@ -74,6 +74,10 @@ class VCardData: organisation: str = "" email: str = "" url: str = "" + emails: list[str] = field(default_factory=list) + urls: list[str] = field(default_factory=list) + url_type: str = "work" + url_types: list[str] = field(default_factory=list) primary_phone: PhoneNumber = field(default_factory=PhoneNumber) extra_phones: list[PhoneNumber] = field(default_factory=list) @@ -103,8 +107,12 @@ def build_vcard(data: VCardData) -> str: if data.organisation.strip(): lines.append(f"ORG:{escape_vcard_text(data.organisation)}") - if data.email.strip(): - lines.append(f"EMAIL;TYPE=work;PREF:{escape_vcard_text(data.email)}") + email_values = [data.email, *data.emails] + email_values = [email for email in email_values if email.strip()] + + for index, email in enumerate(email_values): + email_type = "work;PREF" if index == 0 else "work" + lines.append(f"EMAIL;TYPE={email_type}:{escape_vcard_text(email)}") if not data.primary_phone.is_empty(): phone_type = data.primary_phone.label or "work" @@ -137,9 +145,7 @@ def build_vcard(data: VCardData) -> str: address_type = address.label or "work" street = " ".join( - part.strip() - for part in [address.first_line, address.second_line] - if part.strip() + part.strip() for part in [address.first_line, address.second_line] if part.strip() ) lines.append( @@ -152,11 +158,25 @@ def build_vcard(data: VCardData) -> str: f"{escape_vcard_text(address.country)}" ) + url_values = [] + if data.url.strip(): - lines.append(f"URL;TYPE=work;PREF:{escape_vcard_text(data.url)}") + url_values.append((data.url, data.url_type)) + + for index, url in enumerate(data.urls): + if not url.strip(): + continue + + url_type = data.url_types[index] if index < len(data.url_types) else data.url_type + url_values.append((url, url_type)) + + for index, (url, url_type) in enumerate(url_values): + escaped_url_type = escape_vcard_text(url_type or "work") + type_parameters = f"{escaped_url_type};PREF" if index == 0 else escaped_url_type + lines.append(f"URL;TYPE={type_parameters}:{escape_vcard_text(url)}") revision_time = datetime.now(timezone.utc).isoformat(timespec="milliseconds") lines.append(f"REV:{revision_time}") lines.append("END:VCARD") - return "\r\n".join(lines) + "\r\n" \ No newline at end of file + return "\r\n".join(lines) + "\r\n" diff --git a/tests/test_main_window.py b/tests/test_main_window.py new file mode 100644 index 0000000..eb06fb7 --- /dev/null +++ b/tests/test_main_window.py @@ -0,0 +1,126 @@ +from pathlib import Path + +from PySide6.QtCore import Qt + +from qrgenerator.main import main +from qrgenerator.main_window import MainWindow + + +def test_main_entry_point_is_callable() -> None: + assert callable(main) + + +def test_main_window_starts_with_generate_disabled(qtbot, tmp_path: Path) -> None: + output_path = tmp_path / "qr-code.png" + window = MainWindow(default_output_path=output_path) + qtbot.addWidget(window) + + assert window.windowTitle() + assert window.input_tabs.currentWidget() is window.contact_tab + assert window.output_path_edit.text() == str(output_path) + assert window.status_label.text() == "Ready" + assert not window.generate_button.isEnabled() + + +def test_generate_button_requires_content_and_output_path(qtbot, tmp_path: Path) -> None: + window = MainWindow(default_output_path=tmp_path / "qr-code.png") + qtbot.addWidget(window) + window.input_tabs.setCurrentWidget(window.text_tab) + + window.content_editor.setPlainText("https://example.com") + assert window.generate_button.isEnabled() + + window.output_path_edit.clear() + assert not window.generate_button.isEnabled() + + window.output_path_edit.setText(str(tmp_path / "qr-code.png")) + assert window.generate_button.isEnabled() + + +def test_generate_qr_from_window_writes_file_and_updates_preview(qtbot, tmp_path: Path) -> None: + output_path = tmp_path / "qr-code.png" + window = MainWindow(default_output_path=output_path) + qtbot.addWidget(window) + window.input_tabs.setCurrentWidget(window.text_tab) + + window.content_editor.setPlainText("https://example.com") + qtbot.mouseClick(window.generate_button, Qt.MouseButton.LeftButton) + + assert output_path.exists() + assert window.current_preview_path == output_path + assert "Saved QR code to" in window.status_label.text() + assert window.preview_label.pixmap() is not None + assert not window.preview_label.pixmap().isNull() + + +def test_generate_qr_reports_missing_content_when_called_directly(qtbot, tmp_path: Path) -> None: + window = MainWindow(default_output_path=tmp_path / "qr-code.png") + qtbot.addWidget(window) + window.input_tabs.setCurrentWidget(window.text_tab) + + window.generate_qr() + + assert window.status_label.text() == "Enter content before generating a QR code." + + +def test_contact_tab_builds_vcard_payload_from_repeated_fields(qtbot, tmp_path: Path) -> None: + window = MainWindow(default_output_path=tmp_path / "contact.png") + qtbot.addWidget(window) + window.input_tabs.setCurrentWidget(window.contact_tab) + + window.phone_count_spin.setValue(2) + window.email_count_spin.setValue(2) + window.url_count_spin.setValue(2) + + window.first_name_edit.setText("Jane") + window.last_name_edit.setText("Doe") + window.title_edit.setText("Director") + window.organisation_edit.setText("ACME, Inc.") + + window.phone_fields[0][0].setText("mobile") + window.phone_fields[0][1].setText("+65 1234") + window.phone_fields[1][0].setText("work") + window.phone_fields[1][1].setText("+65 5678") + + window.email_fields[0].setText("jane@example.com") + window.email_fields[1].setText("jane.doe@example.com") + + window.url_fields[0][0].setText("corporate") + window.url_fields[0][1].setText("https://example.com") + window.url_fields[1][1].setText("https://work.example.com") + + address = window.address_fields[0] + address["line_1"].setText("1 Main St") + address["line_2"].setText("Level 2") + address["country"].setText("SG") + address["state"].setText("Central") + address["include_county"].setChecked(True) + address["county"].setText("Singapore") + address["postal_code"].setText("123456") + + payload = window._build_vcard_payload() + + assert "N:Doe;Jane;;;" in payload + assert "FN:Jane Doe" in payload + assert "TITLE:Director" in payload + assert "ORG:ACME\\, Inc." in payload + assert "TEL;TYPE=mobile,voice,pref:+65 1234" in payload + assert "TEL;TYPE=work,voice:+65 5678" in payload + assert "EMAIL;TYPE=work;PREF:jane@example.com" in payload + assert "EMAIL;TYPE=work:jane.doe@example.com" in payload + assert "URL;TYPE=corporate;PREF:https://example.com" in payload + assert "URL;TYPE=ACME\\, Inc.:https://work.example.com" in payload + assert "ADR;TYPE=work:;;1 Main St Level 2;Singapore;Central;123456;SG" in payload + + +def test_contact_tab_generates_qr_file(qtbot, tmp_path: Path) -> None: + output_path = tmp_path / "contact.png" + window = MainWindow(default_output_path=output_path) + qtbot.addWidget(window) + window.input_tabs.setCurrentWidget(window.contact_tab) + + window.first_name_edit.setText("Jane") + qtbot.mouseClick(window.generate_button, Qt.MouseButton.LeftButton) + + assert output_path.exists() + assert "Saved QR code to" in window.status_label.text() diff --git a/tests/test_qr_service.py b/tests/test_qr_service.py new file mode 100644 index 0000000..bc2ded2 --- /dev/null +++ b/tests/test_qr_service.py @@ -0,0 +1,43 @@ +from pathlib import Path + +import pytest +from PIL import Image + +from qrgenerator.qr_service import generate_qr_code, make_background_transparent + + +def test_generate_qr_code_creates_png_file(tmp_path: Path) -> None: + output_path = tmp_path / "nested" / "qr-code.png" + + result = generate_qr_code("https://example.com", output_path) + + assert result == output_path + assert output_path.exists() + + with Image.open(output_path) as image: + assert image.format == "PNG" + assert image.size[0] > 0 + assert image.size[1] > 0 + + +@pytest.mark.parametrize("data", ["", " ", "\n\t"]) +def test_generate_qr_code_rejects_blank_input(tmp_path: Path, data: str) -> None: + with pytest.raises(ValueError, match="QR code data cannot be empty"): + generate_qr_code(data, tmp_path / "qr-code.png") + + +def test_make_background_transparent_converts_white_pixels_only() -> None: + image = Image.new("RGBA", (2, 1)) + image.putdata( + [ + (255, 255, 255, 255), + (0, 0, 0, 255), + ] + ) + + result = make_background_transparent(image) + + assert list(result.getdata()) == [ + (255, 255, 255, 0), + (0, 0, 0, 255), + ] diff --git a/tests/test_vcard_service.py b/tests/test_vcard_service.py new file mode 100644 index 0000000..d9079d8 --- /dev/null +++ b/tests/test_vcard_service.py @@ -0,0 +1,88 @@ +import re + +from qrgenerator.vcard_service import ( + Address, + PhoneNumber, + VCardData, + build_vcard, + escape_vcard_text, + safe_filename, +) + + +def test_escape_vcard_text_escapes_special_characters() -> None: + assert escape_vcard_text(" ACME, Inc.; Line 1\nLine 2\\ ") == ( + "ACME\\, Inc.\\; Line 1\\nLine 2\\\\" + ) + + +def test_safe_filename_replaces_windows_reserved_characters() -> None: + assert safe_filename(' Jane: "QR" / Contact? ') == "Jane_ _QR_ _ Contact_" + + +def test_safe_filename_uses_default_for_blank_value() -> None: + assert safe_filename(" ") == "contact" + + +def test_build_vcard_includes_contact_fields_and_crlf_line_endings() -> None: + vcard = build_vcard( + VCardData( + first_name="Jane", + last_name="Doe", + title="Director", + organisation="ACME, Inc.", + email="jane@example.com", + url="https://example.com", + primary_phone=PhoneNumber(label="mobile", country_code="+65", number="1234"), + extra_phones=[ + PhoneNumber(label="work", country_code="+65", number="5678", preferred=True), + PhoneNumber(label="home"), + ], + addresses=[ + Address( + label="work", + first_line="1 Main St", + second_line="Level 2", + state="Central", + county="Singapore", + postal_code="123456", + country="SG", + ) + ], + ) + ) + + assert vcard.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n") + assert vcard.endswith("END:VCARD\r\n") + assert "\n" not in vcard.replace("\r\n", "") + assert "N:Doe;Jane;;;" in vcard + assert "FN:Jane Doe" in vcard + assert "ORG:ACME\\, Inc." in vcard + assert "TEL;TYPE=mobile,voice,pref:+65 1234" in vcard + assert "TEL;TYPE=work,voice,pref:+65 5678" in vcard + assert "ADR;TYPE=work:;;1 Main St Level 2;Singapore;Central;123456;SG" in vcard + assert re.search(r"REV:\d{4}-\d{2}-\d{2}T", vcard) + + +def test_build_vcard_uses_unnamed_contact_when_name_is_blank() -> None: + vcard = build_vcard(VCardData()) + + assert "FN:Unnamed Contact" in vcard + + +def test_build_vcard_includes_multiple_emails_and_urls() -> None: + vcard = build_vcard( + VCardData( + first_name="Jane", + email="primary@example.com", + emails=["secondary@example.com"], + url="https://primary.example.com", + urls=["https://secondary.example.com"], + url_type="ACME", + ) + ) + + assert "EMAIL;TYPE=work;PREF:primary@example.com" in vcard + assert "EMAIL;TYPE=work:secondary@example.com" in vcard + assert "URL;TYPE=ACME;PREF:https://primary.example.com" in vcard + assert "URL;TYPE=ACME:https://secondary.example.com" in vcard From c65d730bb4d0861bb2dd932d300d23655ea6104c Mon Sep 17 00:00:00 2001 From: Jonathan2745 Date: Sun, 5 Jul 2026 18:32:30 +0800 Subject: [PATCH 4/4] fix(CICD): for ubuntu release --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++---- README.md | 15 --------------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5e2d40..9deef7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,29 @@ jobs: os: ["ubuntu-latest", "windows-latest"] python-version: ["3.11", "3.14"] - env: - QT_QPA_PLATFORM: offscreen - steps: - name: Check out repository uses: actions/checkout@v4 + - name: Install Linux Qt runtime dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libegl1 \ + libgl1 \ + libxkbcommon-x11-0 \ + libxcb-cursor0 \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-randr0 \ + libxcb-render-util0 \ + libxcb-shape0 \ + libxcb-xfixes0 \ + libxcb-xinerama0 \ + xvfb + - name: Set up Python uses: actions/setup-python@v5 with: @@ -40,5 +56,10 @@ jobs: - name: Lint run: python -m ruff check . - - name: Run tests + - name: Run tests on Linux + if: runner.os == 'Linux' + run: xvfb-run -a python -m pytest + + - name: Run tests on Windows + if: runner.os == 'Windows' run: python -m pytest diff --git a/README.md b/README.md index b03c627..bc32d79 100644 --- a/README.md +++ b/README.md @@ -295,21 +295,6 @@ Also ensure `pyproject.toml` includes: packages = ["src/qrgenerator"] ``` -## Development Notes - -The app should keep GUI code separate from QR generation logic. - -Recommended file responsibilities: - -```text -main.py Starts the QApplication -main_window.py Contains the PySide6 window and widgets -qr_service.py Handles QR code generation -config.py Loads .env configuration -tests/ Contains automated tests -``` - -Avoid placing all code in one file once the app grows. This makes the app easier to test, package, and maintain. ## License