diff --git a/frontends/web/app.py b/frontends/web/app.py
index db4e294..523ff4e 100644
--- a/frontends/web/app.py
+++ b/frontends/web/app.py
@@ -89,6 +89,7 @@ from stegasoo.qr_utils import (
compress_data, decompress_data, auto_decompress,
is_compressed, can_fit_in_qr, needs_compression,
generate_qr_code, read_qr_code, extract_key_from_qr,
+ detect_and_crop_qr,
has_qr_write, has_qr_read,
QR_MAX_BINARY, COMPRESSION_PREFIX
)
@@ -368,6 +369,43 @@ def generate_qr_download(token):
return f"Error generating QR code: {e}", 500
+@app.route('/qr/crop', methods=['POST'])
+def qr_crop():
+ """
+ Detect and crop QR code from an image.
+
+ Useful for extracting QR codes from photos taken at an angle,
+ with extra background, etc. Returns the cropped QR as PNG.
+ """
+ if not HAS_QRCODE_READ:
+ return jsonify({'error': 'QR code reading not available (install pyzbar)'}), 501
+
+ image_file = request.files.get('image')
+ if not image_file:
+ return jsonify({'error': 'No image provided'}), 400
+
+ try:
+ image_data = image_file.read()
+
+ # Use the new crop function
+ cropped = detect_and_crop_qr(image_data)
+
+ if cropped is None:
+ return jsonify({'error': 'No QR code detected in image'}), 404
+
+ # Return as downloadable PNG or inline based on query param
+ as_attachment = request.args.get('download', '').lower() in ('1', 'true', 'yes')
+
+ return send_file(
+ io.BytesIO(cropped),
+ mimetype='image/png',
+ as_attachment=as_attachment,
+ download_name='cropped_qr.png'
+ )
+ except Exception as e:
+ return jsonify({'error': f'Error processing image: {e}'}), 500
+
+
@app.route('/generate/download-key', methods=['POST'])
def download_key():
"""Download RSA key as password-protected PEM file."""
diff --git a/frontends/web/templates/decode.html b/frontends/web/templates/decode.html
index 17279a7..648b3f2 100644
--- a/frontends/web/templates/decode.html
+++ b/frontends/web/templates/decode.html
@@ -48,6 +48,59 @@
color: rgba(246, 173, 85, 0.4);
letter-spacing: 1px;
}
+
+/* QR Crop Animation */
+.qr-crop-container {
+ position: relative;
+ overflow: hidden;
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.3);
+}
+
+.qr-crop-container img {
+ display: block;
+ max-height: 120px;
+ width: auto;
+ margin: 0 auto;
+ transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.qr-crop-container .qr-original {
+ opacity: 1;
+}
+
+.qr-crop-container .qr-cropped {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%) scale(0.3);
+ opacity: 0;
+ max-height: 100px;
+}
+
+.qr-crop-container.animating .qr-original {
+ opacity: 0;
+ transform: scale(1.1);
+ filter: blur(4px);
+}
+
+.qr-crop-container.animating .qr-cropped {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+}
+
+.qr-crop-container .crop-badge {
+ position: absolute;
+ bottom: 4px;
+ right: 4px;
+ font-size: 0.65rem;
+ opacity: 0;
+ transition: opacity 0.3s ease 0.4s;
+}
+
+.qr-crop-container.animating .crop-badge {
+ opacity: 1;
+}
@@ -203,7 +256,12 @@
Drop QR image or click to browse
-
+
+
+
![Original]()
+
![Cropped QR]()
+
Detected
+
@@ -496,47 +554,82 @@ document.querySelectorAll('.drop-zone').forEach(zone => {
}
});
-// QR Code RSA Key scanning
+// QR Code RSA Key scanning with crop animation
const rsaKeyQrInput = document.getElementById('rsaKeyQrInput');
-const qrPreview = document.getElementById('qrPreview');
+const qrCropContainer = document.getElementById('qrCropContainer');
+const qrOriginal = document.getElementById('qrOriginal');
+const qrCropped = document.getElementById('qrCropped');
+
if (rsaKeyQrInput) {
rsaKeyQrInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
- // Show image preview
- if (file.type.startsWith('image/')) {
- const reader = new FileReader();
- reader.onload = e => {
- if (qrPreview) {
- qrPreview.src = e.target.result;
- qrPreview.classList.remove('d-none');
+ if (!file.type.startsWith('image/')) return;
+
+ // Reset animation state
+ qrCropContainer.classList.remove('animating');
+ qrCropContainer.classList.add('d-none');
+
+ // Show original image first
+ const reader = new FileReader();
+ reader.onload = e => {
+ qrOriginal.src = e.target.result;
+ qrCropContainer.classList.remove('d-none');
+
+ // Hide the label
+ document.querySelector('#qrDropZone .drop-zone-label').classList.add('d-none');
+
+ // Now fetch cropped version and animate
+ const formData = new FormData();
+ formData.append('image', file);
+
+ fetch('/qr/crop', {
+ method: 'POST',
+ body: formData
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('No QR code detected');
}
- };
- reader.readAsDataURL(file);
- }
-
- // Extract key from QR
- const formData = new FormData();
- formData.append('qr_image', file);
-
- fetch('/extract-key-from-qr', {
- method: 'POST',
- body: formData
- })
- .then(response => response.json())
- .then(data => {
- if (!data.success) {
- alert('QR decode failed: ' + data.error);
- return;
- }
- // Visual feedback
- document.querySelector('#qrDropZone .drop-zone-label').innerHTML =
- 'RSA Key loaded from QR';
- })
- .catch(err => {
- alert('QR decode failed: ' + err);
- });
+ return response.blob();
+ })
+ .then(blob => {
+ // Load cropped image
+ const croppedUrl = URL.createObjectURL(blob);
+ qrCropped.src = croppedUrl;
+
+ // Wait a moment to show original, then animate
+ setTimeout(() => {
+ qrCropContainer.classList.add('animating');
+ }, 400);
+
+ // Also verify key extraction works
+ const keyFormData = new FormData();
+ keyFormData.append('qr_image', file);
+
+ return fetch('/extract-key-from-qr', {
+ method: 'POST',
+ body: keyFormData
+ });
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ document.querySelector('#qrDropZone .drop-zone-label').innerHTML =
+ 'RSA Key loaded';
+ document.querySelector('#qrDropZone .drop-zone-label').classList.remove('d-none');
+ }
+ })
+ .catch(err => {
+ // Crop failed - just show original with error
+ console.log('QR crop/extract error:', err);
+ document.querySelector('#qrDropZone .drop-zone-label').innerHTML =
+ 'No QR detected';
+ document.querySelector('#qrDropZone .drop-zone-label').classList.remove('d-none');
+ });
+ };
+ reader.readAsDataURL(file);
}
});
}
diff --git a/frontends/web/templates/encode.html b/frontends/web/templates/encode.html
index ff77521..47c4e5d 100644
--- a/frontends/web/templates/encode.html
+++ b/frontends/web/templates/encode.html
@@ -52,6 +52,59 @@
color: rgba(246, 173, 85, 0.4);
letter-spacing: 1px;
}
+
+/* QR Crop Animation */
+.qr-crop-container {
+ position: relative;
+ overflow: hidden;
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.3);
+}
+
+.qr-crop-container img {
+ display: block;
+ max-height: 120px;
+ width: auto;
+ margin: 0 auto;
+ transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.qr-crop-container .qr-original {
+ opacity: 1;
+}
+
+.qr-crop-container .qr-cropped {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%) scale(0.3);
+ opacity: 0;
+ max-height: 100px;
+}
+
+.qr-crop-container.animating .qr-original {
+ opacity: 0;
+ transform: scale(1.1);
+ filter: blur(4px);
+}
+
+.qr-crop-container.animating .qr-cropped {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+}
+
+.qr-crop-container .crop-badge {
+ position: absolute;
+ bottom: 4px;
+ right: 4px;
+ font-size: 0.65rem;
+ opacity: 0;
+ transition: opacity 0.3s ease 0.4s;
+}
+
+.qr-crop-container.animating .crop-badge {
+ opacity: 1;
+}
@@ -302,7 +355,12 @@
Drop QR image or click to browse
-
+
+
+
![Original]()
+
![Cropped QR]()
+
Detected
+
@@ -806,47 +864,82 @@ document.addEventListener('paste', function(e) {
}
});
-// QR Code RSA Key scanning
+// QR Code RSA Key scanning with crop animation
const rsaQrInput = document.getElementById('rsaQrInput');
-const qrPreview = document.getElementById('qrPreview');
+const qrCropContainer = document.getElementById('qrCropContainer');
+const qrOriginal = document.getElementById('qrOriginal');
+const qrCropped = document.getElementById('qrCropped');
+
if (rsaQrInput) {
rsaQrInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
const file = this.files[0];
- // Show image preview
- if (file.type.startsWith('image/')) {
- const reader = new FileReader();
- reader.onload = e => {
- if (qrPreview) {
- qrPreview.src = e.target.result;
- qrPreview.classList.remove('d-none');
+ if (!file.type.startsWith('image/')) return;
+
+ // Reset animation state
+ qrCropContainer.classList.remove('animating');
+ qrCropContainer.classList.add('d-none');
+
+ // Show original image first
+ const reader = new FileReader();
+ reader.onload = e => {
+ qrOriginal.src = e.target.result;
+ qrCropContainer.classList.remove('d-none');
+
+ // Hide the label
+ document.querySelector('#qrDropZone .drop-zone-label').classList.add('d-none');
+
+ // Now fetch cropped version and animate
+ const formData = new FormData();
+ formData.append('image', file);
+
+ fetch('/qr/crop', {
+ method: 'POST',
+ body: formData
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('No QR code detected');
}
- };
- reader.readAsDataURL(file);
- }
-
- // Extract key from QR
- const formData = new FormData();
- formData.append('qr_image', file);
-
- fetch('/extract-key-from-qr', {
- method: 'POST',
- body: formData
- })
- .then(response => response.json())
- .then(data => {
- if (!data.success) {
- alert('QR decode failed: ' + data.error);
- return;
- }
- // Visual feedback
- document.querySelector('#qrDropZone .drop-zone-label').innerHTML =
- 'RSA Key loaded from QR';
- })
- .catch(err => {
- alert('QR decode failed: ' + err);
- });
+ return response.blob();
+ })
+ .then(blob => {
+ // Load cropped image
+ const croppedUrl = URL.createObjectURL(blob);
+ qrCropped.src = croppedUrl;
+
+ // Wait a moment to show original, then animate
+ setTimeout(() => {
+ qrCropContainer.classList.add('animating');
+ }, 400);
+
+ // Also verify key extraction works
+ const keyFormData = new FormData();
+ keyFormData.append('qr_image', file);
+
+ return fetch('/extract-key-from-qr', {
+ method: 'POST',
+ body: keyFormData
+ });
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ document.querySelector('#qrDropZone .drop-zone-label').innerHTML =
+ 'RSA Key loaded';
+ document.querySelector('#qrDropZone .drop-zone-label').classList.remove('d-none');
+ }
+ })
+ .catch(err => {
+ // Crop failed - just show original with error
+ console.log('QR crop/extract error:', err);
+ document.querySelector('#qrDropZone .drop-zone-label').innerHTML =
+ 'No QR detected';
+ document.querySelector('#qrDropZone .drop-zone-label').classList.remove('d-none');
+ });
+ };
+ reader.readAsDataURL(file);
}
});
}
diff --git a/frontends/web/templates/generate.html b/frontends/web/templates/generate.html
index f00830f..3f8d7e5 100644
--- a/frontends/web/templates/generate.html
+++ b/frontends/web/templates/generate.html
@@ -62,11 +62,14 @@
-
@@ -498,6 +501,16 @@ if (useRsaCheck) {
});
}
+// RSA key size QR warning
+const rsaBitsSelect = document.getElementById('rsaBitsSelect');
+const rsaQrWarning = document.getElementById('rsaQrWarning');
+
+if (rsaBitsSelect && rsaQrWarning) {
+ rsaBitsSelect.addEventListener('change', function() {
+ rsaQrWarning.classList.toggle('d-none', parseInt(this.value) <= 3072);
+ });
+}
+
// PIN visibility toggle
let pinHidden = false;
function togglePinVisibility() {