Fase 3 feedback round: style preview, icon upload, font choice, optional bitcoin amount, WPA3

- Default the Owner select to the creator's own account instead of "All", so
  superadmin-created codes no longer silently become visible to every admin
  (the underlying NULL-fallback sharing behavior for an explicit "All" choice
  is unchanged).
- Add a live color/precision/size preview swatch next to the preset picker.
- Let the frame text use a chosen DejaVu font + font size instead of a fixed
  GD bitmap font.
- Add an optional self-uploaded icon rendered above the qr code (not embedded
  in it, so scanability is unaffected).
- Make the Bitcoin qr amount optional; a standing wallet address is useful
  without forcing a one-off amount per code.
- Add a WPA3 option to the WiFi qr encryption select.
- Fix a real bug surfaced while testing the preview/style JS: qrcode_options.php
  was included once per static qr type (16 times on one page) and each
  inclusion re-executed <script src="qrcode-style-tools.js">, so every button
  click fired once per type - e.g. saving one preset wrote 16 duplicate rows,
  and every tab except the first ("Text") had dead random-style/preset
  buttons since only the first DOM match ever got a listener. Moved the
  script include to load once per page and rewrote the JS to scope every
  lookup to the triggering element's own tab-pane/form instead of relying on
  getElementById's first-match behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196bLhZhuxwK3MkHuKLe5WB
This commit is contained in:
2026-07-09 23:11:36 +02:00
parent be9164850a
commit 9cdfbe7a10
12 changed files with 534 additions and 77 deletions
+121 -41
View File
@@ -1,12 +1,23 @@
// Preset system + random style button for the qr code generation forms (Fase 3, priority 2). // Preset system + random style button for the qr code generation forms (Fase 3, priority 2).
//
// The static qr "add" page stacks all qr-type forms (text/email/.../wifi/bitcoin/...) into
// the DOM at once as Bootstrap tab-panes, and every one of them repeats the same element ids
// (foreground, background, size, random_style_btn, ...). getElementById/getElementsBy* only
// ever finds the *first* of those (the "Text" tab), so every helper below is scoped to the
// specific tab-pane/form the triggering element lives in, and wired up via querySelectorAll
// so every tab gets working listeners, not just the first one.
(function () { (function () {
function csrfToken() { function csrfToken() {
var meta = document.querySelector('meta[name="csrf-token"]'); var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : ''; return meta ? meta.getAttribute('content') : '';
} }
function setColor(id, hex) { function scopeOf(el) {
var input = document.getElementById(id); return el.closest('.tab-pane') || el.closest('form') || document;
}
function setColor(scope, id, hex) {
var input = scope.querySelector('#' + id);
if (!input) { if (!input) {
return; return;
} }
@@ -27,12 +38,35 @@
return '#' + ('000000' + value).slice(-6); return '#' + ('000000' + value).slice(-6);
} }
function loadPresets() { function updateStylePreview(scope) {
var select = document.getElementById('preset_select'); var swatch = scope.querySelector('#style_preview_swatch');
if (!select) { var text = scope.querySelector('#style_preview_text');
if (!swatch || !text) {
return; return;
} }
var foreground = scope.querySelector('#foreground');
var background = scope.querySelector('#background');
var levelSelect = scope.querySelector('select[name="level"]');
var sizeSelect = scope.querySelector('#size');
var fg = foreground ? foreground.value : '#000000';
var bg = background ? background.value : '#ffffff';
swatch.style.background = bg;
swatch.style.borderColor = fg;
var parts = [];
if (levelSelect) {
parts.push('Precision: ' + levelSelect.value);
}
if (sizeSelect) {
parts.push('Size: ' + sizeSelect.value + 'px');
}
text.textContent = parts.join(' · ');
}
function loadPresetsInto(select) {
fetch('presets.php?action=list') fetch('presets.php?action=list')
.then(function (response) { return response.json(); }) .then(function (response) { return response.json(); })
.then(function (json) { .then(function (json) {
@@ -54,43 +88,69 @@
} }
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
loadPresets(); document.querySelectorAll('#preset_select').forEach(loadPresetsInto);
var randomBtn = document.getElementById('random_style_btn'); document.querySelectorAll('#style_preview').forEach(function (row) {
if (randomBtn) { updateStylePreview(scopeOf(row));
randomBtn.addEventListener('click', function () { });
setColor('foreground', randomHexColor());
setColor('background', randomHexColor()); document.querySelectorAll('#foreground, #background').forEach(function (input) {
input.addEventListener('change', function () {
updateStylePreview(scopeOf(input));
}); });
} });
var presetSelect = document.getElementById('preset_select'); document.querySelectorAll('select[name="level"]').forEach(function (select) {
if (presetSelect) { select.addEventListener('change', function () {
updateStylePreview(scopeOf(select));
});
});
document.querySelectorAll('#size').forEach(function (select) {
select.addEventListener('change', function () {
updateStylePreview(scopeOf(select));
});
});
document.querySelectorAll('#random_style_btn').forEach(function (randomBtn) {
randomBtn.addEventListener('click', function () {
var scope = scopeOf(randomBtn);
setColor(scope, 'foreground', randomHexColor());
setColor(scope, 'background', randomHexColor());
updateStylePreview(scope);
});
});
document.querySelectorAll('#preset_select').forEach(function (presetSelect) {
presetSelect.addEventListener('change', function () { presetSelect.addEventListener('change', function () {
var option = presetSelect.options[presetSelect.selectedIndex]; var option = presetSelect.options[presetSelect.selectedIndex];
if (!option.value) { if (!option.value) {
return; return;
} }
setColor('foreground', option.dataset.foreground); var scope = scopeOf(presetSelect);
setColor('background', option.dataset.background);
var levelSelect = document.querySelector('select[name="level"]'); setColor(scope, 'foreground', option.dataset.foreground);
setColor(scope, 'background', option.dataset.background);
var levelSelect = scope.querySelector('select[name="level"]');
if (levelSelect) { if (levelSelect) {
levelSelect.value = option.dataset.level; levelSelect.value = option.dataset.level;
} }
var sizeSelect = document.getElementById('size'); var sizeSelect = scope.querySelector('#size');
if (sizeSelect) { if (sizeSelect) {
sizeSelect.value = option.dataset.size; sizeSelect.value = option.dataset.size;
} }
});
}
var saveBtn = document.getElementById('preset_save_btn'); updateStylePreview(scope);
if (saveBtn) { });
});
document.querySelectorAll('#preset_save_btn').forEach(function (saveBtn) {
saveBtn.addEventListener('click', function () { saveBtn.addEventListener('click', function () {
var nameInput = document.getElementById('preset_name'); var scope = scopeOf(saveBtn);
var nameInput = scope.querySelector('#preset_name');
var name = nameInput.value.trim(); var name = nameInput.value.trim();
if (!name) { if (!name) {
@@ -98,13 +158,18 @@
return; return;
} }
var foreground = scope.querySelector('#foreground').value;
var background = scope.querySelector('#background').value;
var level = scope.querySelector('select[name="level"]').value;
var size = scope.querySelector('#size').value;
var body = new URLSearchParams(); var body = new URLSearchParams();
body.set('action', 'save'); body.set('action', 'save');
body.set('name', name); body.set('name', name);
body.set('foreground', document.getElementById('foreground').value); body.set('foreground', foreground);
body.set('background', document.getElementById('background').value); body.set('background', background);
body.set('level', document.querySelector('select[name="level"]').value); body.set('level', level);
body.set('size', document.getElementById('size').value); body.set('size', size);
fetch('presets.php', { fetch('presets.php', {
method: 'POST', method: 'POST',
@@ -118,24 +183,33 @@
return; return;
} }
var option = document.createElement('option'); // The preset list is shared across every tab, so mirror the new
option.value = json.data.id; // option into every preset_select on the page, not just this one.
option.textContent = json.data.name; document.querySelectorAll('#preset_select').forEach(function (select) {
option.dataset.foreground = document.getElementById('foreground').value; var option = document.createElement('option');
option.dataset.background = document.getElementById('background').value; option.value = json.data.id;
option.dataset.level = document.querySelector('select[name="level"]').value; option.textContent = json.data.name;
option.dataset.size = document.getElementById('size').value; option.dataset.foreground = foreground;
presetSelect.appendChild(option); option.dataset.background = background;
presetSelect.value = option.value; option.dataset.level = level;
option.dataset.size = size;
select.appendChild(option);
if (select === scope.querySelector('#preset_select')) {
select.value = option.value;
}
});
nameInput.value = ''; nameInput.value = '';
}) })
.catch(function () { alert('Could not save preset (network error).'); }); .catch(function () { alert('Could not save preset (network error).'); });
}); });
} });
var deleteBtn = document.getElementById('preset_delete_btn'); document.querySelectorAll('#preset_delete_btn').forEach(function (deleteBtn) {
if (deleteBtn) {
deleteBtn.addEventListener('click', function () { deleteBtn.addEventListener('click', function () {
var scope = scopeOf(deleteBtn);
var presetSelect = scope.querySelector('#preset_select');
var option = presetSelect.options[presetSelect.selectedIndex]; var option = presetSelect.options[presetSelect.selectedIndex];
if (!option.value) { if (!option.value) {
return; return;
@@ -157,13 +231,19 @@
.then(function (response) { return response.json(); }) .then(function (response) { return response.json(); })
.then(function (json) { .then(function (json) {
if (json.status === 200) { if (json.status === 200) {
option.remove(); // Remove the matching option from every preset_select on the page.
document.querySelectorAll('#preset_select').forEach(function (select) {
var match = select.querySelector('option[value="' + option.value + '"]');
if (match) {
match.remove();
}
});
} else { } else {
alert('Could not delete preset: ' + json.data); alert('Could not delete preset: ' + json.data);
} }
}) })
.catch(function () { alert('Could not delete preset (network error).'); }); .catch(function () { alert('Could not delete preset (network error).'); });
}); });
} });
}); });
})(); })();
+11 -2
View File
@@ -37,7 +37,7 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && isset($_POST["edit"])) {
$dynamic_qrcode_instance->editQrcode($_POST); $dynamic_qrcode_instance->editQrcode($_POST);
} }
if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"])) { if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"]) && !isset($_POST["del_id"])) {
if( if(
isset($_POST["foreground"]) && isset($_POST["foreground"]) &&
isset($_POST["background"]) && isset($_POST["background"]) &&
@@ -45,8 +45,17 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"])) {
isset($_POST["filename"]) && isset($_POST["filename"]) &&
isset($_POST["format"]) && isset($_POST["format"]) &&
isset($_POST["id_owner"]) isset($_POST["id_owner"])
) ) {
$icon_upload = qr_handle_icon_upload('icon');
if (!$icon_upload['ok']) {
$_SESSION['failure'] = $icon_upload['error'];
header('Location: ' . basename(__FILE__));
exit;
}
$_POST['icon_tmp_path'] = $icon_upload['path'];
$dynamic_qrcode_instance->addQrcode($_POST); $dynamic_qrcode_instance->addQrcode($_POST);
}
} }
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
+41 -2
View File
@@ -54,6 +54,7 @@
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<label>&nbsp;</label>
<button type="button" id="random_style_btn" class="btn btn-outline-secondary btn-block"> <button type="button" id="random_style_btn" class="btn btn-outline-secondary btn-block">
<i class="fa fa-dice"></i> Random style <i class="fa fa-dice"></i> Random style
</button> </button>
@@ -80,6 +81,14 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-6 col-md-3">
<label>Style preview</label>
<div id="style_preview" class="d-flex align-items-center">
<span id="style_preview_swatch" style="display:inline-block;width:38px;height:38px;border:3px solid #000;background:#fff;border-radius:4px;"></span>
<small id="style_preview_text" class="ml-2 text-muted"></small>
</div>
</div>
</div> </div>
</div> </div>
@@ -136,6 +145,35 @@
<small class="form-text text-muted">Optional label rendered below the code. Only applies to PNG/JPEG/GIF, not SVG/EPS.</small> <small class="form-text text-muted">Optional label rendered below the code. Only applies to PNG/JPEG/GIF, not SVG/EPS.</small>
</div> </div>
</div> </div>
<div class="col-6 col-md-2">
<div class="form-group">
<label for="frame_font">Frame font</label>
<select name="frame_font" id="frame_font" class="form-control">
<option value="sans" selected>Sans</option>
<option value="sans-bold">Sans Bold</option>
<option value="serif">Serif</option>
<option value="serif-bold">Serif Bold</option>
<option value="mono">Monospace</option>
<option value="mono-bold">Monospace Bold</option>
</select>
</div>
</div>
<div class="col-6 col-md-2">
<div class="form-group">
<label for="frame_font_size">Frame font size</label>
<input type="number" name="frame_font_size" id="frame_font_size" value="16" min="8" max="60" class="form-control">
</div>
</div>
<div class="col-sm-4">
<div class="form-group">
<label for="icon">Icon above QR code</label>
<input type="file" name="icon" id="icon" accept="image/png,image/jpeg,image/gif" class="form-control-file">
<small class="form-text text-muted">Optional. PNG/JPEG/GIF, max 1MB. Shown above the code (not embedded in it). Only applies to PNG/JPEG/GIF output.</small>
</div>
</div>
</div> </div>
</div> </div>
@@ -146,7 +184,7 @@
<div class="form-group"> <div class="form-group">
<label for="id_owner">Owner *</label> <label for="id_owner">Owner *</label>
<select name="id_owner" class="form-control"> <select name="id_owner" class="form-control">
<option value="" selected>All</option> <option value="">All (shared with every admin)</option>
<?php <?php
require_once BASE_PATH . '/lib/Users/Users.php'; require_once BASE_PATH . '/lib/Users/Users.php';
@@ -154,8 +192,9 @@
$users = $users_instance->getAllUsers(); $users = $users_instance->getAllUsers();
foreach ($users as $user) { foreach ($users as $user) {
$is_self = (int) $user["id"] === (int) $_SESSION["user_id"];
?> ?>
<option value="<?php echo $user["id"];?>"><?php echo $user["username"];?></option> <option value="<?php echo $user["id"];?>" <?php echo $is_self ? 'selected' : ''; ?>><?php echo $user["username"];?></option>
<?php } ?> <?php } ?>
</select> </select>
</div> </div>
+2
View File
@@ -105,4 +105,6 @@
</div> </div>
</div> </div>
</div><!-- /.card --> </div><!-- /.card -->
<script src="dist/js/qrcode-style-tools.js?nocache=<?php print rand();?>"></script>
</fieldset> </fieldset>
+49 -3
View File
@@ -58,6 +58,7 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
<div class="col-sm-12 mb-2"> <div class="col-sm-12 mb-2">
<div class="row"> <div class="row">
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<label>&nbsp;</label>
<button type="button" id="random_style_btn" class="btn btn-outline-secondary btn-block"> <button type="button" id="random_style_btn" class="btn btn-outline-secondary btn-block">
<i class="fa fa-dice"></i> Random style <i class="fa fa-dice"></i> Random style
</button> </button>
@@ -84,10 +85,25 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
</div> </div>
</div> </div>
</div> </div>
<div class="col-6 col-md-3">
<label>Style preview</label>
<div id="style_preview" class="d-flex align-items-center">
<span id="style_preview_swatch" style="display:inline-block;width:38px;height:38px;border:3px solid #000;background:#fff;border-radius:4px;"></span>
<small id="style_preview_text" class="ml-2 text-muted"></small>
</div>
</div>
</div> </div>
</div> </div>
<script src="dist/js/qrcode-style-tools.js?nocache=<?php print rand();?>"></script> <!--
Note: no <script src="dist/js/qrcode-style-tools.js"> tag here on purpose. This
partial is included once per qr type on the static "add" page (form_static_add.php),
which stacks all types into the DOM as tab-panes; including the script here would load
and execute it once per type, each execution re-attaching its own listeners to every
button on the page. The script is included exactly once by the pages that use this
partial (form_static_add.php and form_dynamic_add.php).
-->
<!-- Its use is not recommended. Read the documentation <!-- Its use is not recommended. Read the documentation
<div class="form-group"> <div class="form-group">
@@ -130,6 +146,35 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
<small class="form-text text-muted">Optional label rendered below the code. Only applies to PNG/JPEG/GIF, not SVG/EPS.</small> <small class="form-text text-muted">Optional label rendered below the code. Only applies to PNG/JPEG/GIF, not SVG/EPS.</small>
</div> </div>
</div> </div>
<div class="col-6 col-md-2">
<div class="form-group">
<label for="frame_font">Frame font</label>
<select name="frame_font" id="frame_font" class="form-control">
<option value="sans" selected>Sans</option>
<option value="sans-bold">Sans Bold</option>
<option value="serif">Serif</option>
<option value="serif-bold">Serif Bold</option>
<option value="mono">Monospace</option>
<option value="mono-bold">Monospace Bold</option>
</select>
</div>
</div>
<div class="col-6 col-md-2">
<div class="form-group">
<label for="frame_font_size">Frame font size</label>
<input type="number" name="frame_font_size" id="frame_font_size" value="16" min="8" max="60" class="form-control">
</div>
</div>
<div class="col-sm-4">
<div class="form-group">
<label for="icon">Icon above QR code</label>
<input type="file" name="icon" id="icon" accept="image/png,image/jpeg,image/gif" class="form-control-file">
<small class="form-text text-muted">Optional. PNG/JPEG/GIF, max 1MB. Shown above the code (not embedded in it). Only applies to PNG/JPEG/GIF output.</small>
</div>
</div>
</div> </div>
</div> </div>
@@ -140,7 +185,7 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
<div class="form-group"> <div class="form-group">
<label for="id_owner">Owner *</label> <label for="id_owner">Owner *</label>
<select name="id_owner" class="form-control"> <select name="id_owner" class="form-control">
<option value="" selected>All</option> <option value="">All (shared with every admin)</option>
<?php <?php
require_once BASE_PATH . '/lib/Users/Users.php'; require_once BASE_PATH . '/lib/Users/Users.php';
@@ -148,8 +193,9 @@ if (QRCODE_GENERATOR === "internal-chillerlan.qrcode") {
$users = $users_instance->getAllUsers(); $users = $users_instance->getAllUsers();
foreach ($users as $user) { foreach ($users as $user) {
$is_self = (int) $user["id"] === (int) $_SESSION["user_id"];
?> ?>
<option value="<?php echo $user["id"];?>"><?php echo $user["username"];?></option> <option value="<?php echo $user["id"];?>" <?php echo $is_self ? 'selected' : ''; ?>><?php echo $user["username"];?></option>
<?php } ?> <?php } ?>
</select> </select>
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<div class="form-group"> <div class="form-group">
<label>Amount *</label> <label>Amount</label>
<div class="input-group"> <div class="input-group">
<input type="number" name="amount" value="" placeholder="" class="form-control" step="0.0001"> <input type="number" name="amount" value="" placeholder="" class="form-control" step="0.0001">
<div class="input-group-append"> <div class="input-group-append">
+1
View File
@@ -10,6 +10,7 @@
<label>Encryption *</label> <label>Encryption *</label>
<select name="encryption" class="form-control"> <select name="encryption" class="form-control">
<option value="WPA" Selected>WPA/WPA2</option> <option value="WPA" Selected>WPA/WPA2</option>
<option value="WPA3">WPA3</option>
<option value="WEP">WEP</option> <option value="WEP">WEP</option>
<option value="">None</option> <option value="">None</option>
</select> </select>
+39
View File
@@ -117,6 +117,45 @@ function paginationLinks($current_page, $total_pages, $base_url) {
return $html; return $html;
} }
/**
* Handles the optional "icon above the qr code" upload. Validates the actual image
* type (not just the extension/mime the browser claims) and stores the file outside
* the document root, next to the generated qr codes.
*/
function qr_handle_icon_upload($fileKey = 'icon') {
if (!isset($_FILES[$fileKey]) || $_FILES[$fileKey]['error'] === UPLOAD_ERR_NO_FILE) {
return ['ok' => true, 'path' => null];
}
if ($_FILES[$fileKey]['error'] !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => 'Icon upload failed.'];
}
if ($_FILES[$fileKey]['size'] > 1024 * 1024) {
return ['ok' => false, 'error' => 'Icon must be smaller than 1MB.'];
}
$info = @getimagesize($_FILES[$fileKey]['tmp_name']);
$allowed = [IMAGETYPE_PNG => 'png', IMAGETYPE_JPEG => 'jpg', IMAGETYPE_GIF => 'gif'];
if ($info === false || !isset($allowed[$info[2]])) {
return ['ok' => false, 'error' => 'Icon must be a PNG, JPEG or GIF image.'];
}
$dir = SAVED_QRCODE_DIRECTORY . 'icons/';
if (!is_dir($dir)) {
mkdir($dir, 0750, true);
}
$destination = $dir . bin2hex(random_bytes(16)) . '.' . $allowed[$info[2]];
if (!move_uploaded_file($_FILES[$fileKey]['tmp_name'], $destination)) {
return ['ok' => false, 'error' => 'Could not store the uploaded icon.'];
}
return ['ok' => true, 'path' => $destination];
}
function base_url() { function base_url() {
require_once(__DIR__ . '/../config/environment.php'); require_once(__DIR__ . '/../config/environment.php');
if (defined('BASE_URL') && BASE_URL !== null) { if (defined('BASE_URL') && BASE_URL !== null) {
+107 -8
View File
@@ -72,11 +72,29 @@ class Qrcode {
return $format; return $format;
} }
const FRAME_FONT_DIR = '/usr/share/fonts/truetype/dejavu/';
const ALLOWED_FRAME_FONTS = [
'sans' => 'DejaVuSans.ttf',
'sans-bold' => 'DejaVuSans-Bold.ttf',
'serif' => 'DejaVuSerif.ttf',
'serif-bold' => 'DejaVuSerif-Bold.ttf',
'mono' => 'DejaVuSansMono.ttf',
'mono-bold' => 'DejaVuSansMono-Bold.ttf',
];
private static function resolveFrameFont($fontKey) {
$file = self::ALLOWED_FRAME_FONTS[$fontKey] ?? self::ALLOWED_FRAME_FONTS['sans'];
$path = self::FRAME_FONT_DIR . $file;
return is_file($path) ? $path : null;
}
/** /**
* Renders an optional text label below the qr code. Only supported for raster * Renders an optional text label below the qr code. Only supported for raster
* formats (png/jpg/jpeg/gif) via GD; a no-op for svg/svgbw/eps. * formats (png/jpg/jpeg/gif) via GD; a no-op for svg/svgbw/eps.
*/ */
private function addFrameText($path, $format, $text) { private function addFrameText($path, $format, $text, $fontKey = 'sans', $fontSize = 16) {
$text = trim((string) $text); $text = trim((string) $text);
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif']; $loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif']; $savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
@@ -85,6 +103,9 @@ class Qrcode {
return; return;
} }
$fontFile = self::resolveFrameFont($fontKey);
$fontSize = min(max((int) $fontSize, 8), 60);
$source = @$loaders[$format]($path); $source = @$loaders[$format]($path);
if ($source === false) { if ($source === false) {
return; return;
@@ -92,7 +113,7 @@ class Qrcode {
$width = imagesx($source); $width = imagesx($source);
$height = imagesy($source); $height = imagesy($source);
$padding = 30; $padding = $fontFile !== null ? $fontSize + 20 : 30;
$canvas = imagecreatetruecolor($width, $height + $padding); $canvas = imagecreatetruecolor($width, $height + $padding);
$white = imagecolorallocate($canvas, 255, 255, 255); $white = imagecolorallocate($canvas, 255, 255, 255);
@@ -100,11 +121,20 @@ class Qrcode {
imagefill($canvas, 0, 0, $white); imagefill($canvas, 0, 0, $white);
imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height); imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height);
$font = 5; if ($fontFile !== null && function_exists('imagettftext')) {
$text_width = imagefontwidth($font) * strlen($text); $bbox = imagettfbbox($fontSize, 0, $fontFile, $text);
$x = max(0, (int) (($width - $text_width) / 2)); $text_width = abs($bbox[2] - $bbox[0]);
$y = $height + (int) (($padding - imagefontheight($font)) / 2); $text_height = abs($bbox[1] - $bbox[7]);
imagestring($canvas, $font, $x, $y, $text, $black); $x = max(0, (int) (($width - $text_width) / 2));
$y = $height + (int) (($padding + $text_height) / 2);
imagettftext($canvas, $fontSize, 0, $x, $y, $black, $fontFile, $text);
} else {
$font = 5;
$text_width = imagefontwidth($font) * strlen($text);
$x = max(0, (int) (($width - $text_width) / 2));
$y = $height + (int) (($padding - imagefontheight($font)) / 2);
imagestring($canvas, $font, $x, $y, $text, $black);
}
$savers[$format]($canvas, $path); $savers[$format]($canvas, $path);
@@ -112,6 +142,70 @@ class Qrcode {
imagedestroy($canvas); imagedestroy($canvas);
} }
/**
* Renders an optional user-uploaded icon above the qr code (not embedded inside
* it, so scanability is unaffected). Only supported for raster formats via GD.
*/
private function addTopIcon($path, $format, $iconPath) {
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
if (!$iconPath || !is_file($iconPath) || !isset($loaders[$format]) || !is_file($path)) {
return;
}
$iconInfo = @getimagesize($iconPath);
$iconLoaders = [IMAGETYPE_PNG => 'imagecreatefrompng', IMAGETYPE_JPEG => 'imagecreatefromjpeg', IMAGETYPE_GIF => 'imagecreatefromgif'];
if ($iconInfo === false || !isset($iconLoaders[$iconInfo[2]])) {
return;
}
$source = @$loaders[$format]($path);
$icon = @$iconLoaders[$iconInfo[2]]($iconPath);
if ($source === false || $icon === false) {
return;
}
$width = imagesx($source);
$height = imagesy($source);
$iconWidth = imagesx($icon);
$iconHeight = imagesy($icon);
$maxIconHeight = (int) ($height * 0.25);
$scale = min($maxIconHeight / $iconHeight, ($width * 0.6) / $iconWidth, 1);
$targetWidth = max(1, (int) ($iconWidth * $scale));
$targetHeight = max(1, (int) ($iconHeight * $scale));
$margin = 15;
$topPadding = $targetHeight + ($margin * 2);
$canvas = imagecreatetruecolor($width, $height + $topPadding);
$white = imagecolorallocate($canvas, 255, 255, 255);
imagefill($canvas, 0, 0, $white);
$resizedIcon = imagecreatetruecolor($targetWidth, $targetHeight);
imagealphablending($resizedIcon, false);
imagesavealpha($resizedIcon, true);
$transparent = imagecolorallocatealpha($resizedIcon, 0, 0, 0, 127);
imagefill($resizedIcon, 0, 0, $transparent);
imagealphablending($icon, true);
imagecopyresampled($resizedIcon, $icon, 0, 0, 0, 0, $targetWidth, $targetHeight, $iconWidth, $iconHeight);
$x = (int) (($width - $targetWidth) / 2);
imagecopy($canvas, $resizedIcon, $x, $margin, 0, 0, $targetWidth, $targetHeight);
imagecopy($canvas, $source, 0, $topPadding, 0, 0, $width, $height);
$savers[$format]($canvas, $path);
imagedestroy($source);
imagedestroy($icon);
imagedestroy($resizedIcon);
imagedestroy($canvas);
}
public function getQrcode($id) { public function getQrcode($id) {
$db = getDbInstance(); $db = getDbInstance();
@@ -397,7 +491,12 @@ class Qrcode {
throw new \RuntimeException($e->getMessage()); throw new \RuntimeException($e->getMessage());
} }
$this->addFrameText($filename, $fileExt, $input_data['frame_text'] ?? ''); $this->addTopIcon($filename, $fileExt, $input_data['icon_tmp_path'] ?? null);
$this->addFrameText($filename, $fileExt, $input_data['frame_text'] ?? '', $input_data['frame_font'] ?? 'sans', $input_data['frame_font_size'] ?? 16);
if (!empty($input_data['icon_tmp_path'])) {
@unlink($input_data['icon_tmp_path']);
}
// If you want you can customi<e qr code with logo // If you want you can customi<e qr code with logo
//$this->addLogo($data_to_db['qrcode'], $options['optionlogo']); //$this->addLogo($data_to_db['qrcode'], $options['optionlogo']);
+113 -9
View File
@@ -57,11 +57,29 @@ class Qrcode {
return $format; return $format;
} }
const FRAME_FONT_DIR = '/usr/share/fonts/truetype/dejavu/';
const ALLOWED_FRAME_FONTS = [
'sans' => 'DejaVuSans.ttf',
'sans-bold' => 'DejaVuSans-Bold.ttf',
'serif' => 'DejaVuSerif.ttf',
'serif-bold' => 'DejaVuSerif-Bold.ttf',
'mono' => 'DejaVuSansMono.ttf',
'mono-bold' => 'DejaVuSansMono-Bold.ttf',
];
private static function resolveFrameFont($fontKey) {
$file = self::ALLOWED_FRAME_FONTS[$fontKey] ?? self::ALLOWED_FRAME_FONTS['sans'];
$path = self::FRAME_FONT_DIR . $file;
return is_file($path) ? $path : null;
}
/** /**
* Renders an optional text label below the qr code. Only supported for raster * Renders an optional text label below the qr code. Only supported for raster
* formats (png/jpg/jpeg/gif) via GD; a no-op for svg/svgbw/eps. * formats (png/jpg/jpeg/gif) via GD; a no-op for svg/svgbw/eps.
*/ */
private function addFrameText($path, $format, $text) { private function addFrameText($path, $format, $text, $fontKey = 'sans', $fontSize = 16) {
$text = trim((string) $text); $text = trim((string) $text);
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif']; $loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif']; $savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
@@ -70,6 +88,9 @@ class Qrcode {
return; return;
} }
$fontFile = self::resolveFrameFont($fontKey);
$fontSize = min(max((int) $fontSize, 8), 60);
$source = @$loaders[$format]($path); $source = @$loaders[$format]($path);
if ($source === false) { if ($source === false) {
return; return;
@@ -77,7 +98,7 @@ class Qrcode {
$width = imagesx($source); $width = imagesx($source);
$height = imagesy($source); $height = imagesy($source);
$padding = 30; $padding = $fontFile !== null ? $fontSize + 20 : 30;
$canvas = imagecreatetruecolor($width, $height + $padding); $canvas = imagecreatetruecolor($width, $height + $padding);
$white = imagecolorallocate($canvas, 255, 255, 255); $white = imagecolorallocate($canvas, 255, 255, 255);
@@ -85,11 +106,20 @@ class Qrcode {
imagefill($canvas, 0, 0, $white); imagefill($canvas, 0, 0, $white);
imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height); imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height);
$font = 5; if ($fontFile !== null && function_exists('imagettftext')) {
$text_width = imagefontwidth($font) * strlen($text); $bbox = imagettfbbox($fontSize, 0, $fontFile, $text);
$x = max(0, (int) (($width - $text_width) / 2)); $text_width = abs($bbox[2] - $bbox[0]);
$y = $height + (int) (($padding - imagefontheight($font)) / 2); $text_height = abs($bbox[1] - $bbox[7]);
imagestring($canvas, $font, $x, $y, $text, $black); $x = max(0, (int) (($width - $text_width) / 2));
$y = $height + (int) (($padding + $text_height) / 2);
imagettftext($canvas, $fontSize, 0, $x, $y, $black, $fontFile, $text);
} else {
$font = 5;
$text_width = imagefontwidth($font) * strlen($text);
$x = max(0, (int) (($width - $text_width) / 2));
$y = $height + (int) (($padding - imagefontheight($font)) / 2);
imagestring($canvas, $font, $x, $y, $text, $black);
}
$savers[$format]($canvas, $path); $savers[$format]($canvas, $path);
@@ -97,6 +127,70 @@ class Qrcode {
imagedestroy($canvas); imagedestroy($canvas);
} }
/**
* Renders an optional user-uploaded icon above the qr code (not embedded inside
* it, so scanability is unaffected). Only supported for raster formats via GD.
*/
private function addTopIcon($path, $format, $iconPath) {
$loaders = ['png' => 'imagecreatefrompng', 'jpg' => 'imagecreatefromjpeg', 'jpeg' => 'imagecreatefromjpeg', 'gif' => 'imagecreatefromgif'];
$savers = ['png' => 'imagepng', 'jpg' => 'imagejpeg', 'jpeg' => 'imagejpeg', 'gif' => 'imagegif'];
if (!$iconPath || !is_file($iconPath) || !isset($loaders[$format]) || !is_file($path)) {
return;
}
$iconInfo = @getimagesize($iconPath);
$iconLoaders = [IMAGETYPE_PNG => 'imagecreatefrompng', IMAGETYPE_JPEG => 'imagecreatefromjpeg', IMAGETYPE_GIF => 'imagecreatefromgif'];
if ($iconInfo === false || !isset($iconLoaders[$iconInfo[2]])) {
return;
}
$source = @$loaders[$format]($path);
$icon = @$iconLoaders[$iconInfo[2]]($iconPath);
if ($source === false || $icon === false) {
return;
}
$width = imagesx($source);
$height = imagesy($source);
$iconWidth = imagesx($icon);
$iconHeight = imagesy($icon);
$maxIconHeight = (int) ($height * 0.25);
$scale = min($maxIconHeight / $iconHeight, ($width * 0.6) / $iconWidth, 1);
$targetWidth = max(1, (int) ($iconWidth * $scale));
$targetHeight = max(1, (int) ($iconHeight * $scale));
$margin = 15;
$topPadding = $targetHeight + ($margin * 2);
$canvas = imagecreatetruecolor($width, $height + $topPadding);
$white = imagecolorallocate($canvas, 255, 255, 255);
imagefill($canvas, 0, 0, $white);
$resizedIcon = imagecreatetruecolor($targetWidth, $targetHeight);
imagealphablending($resizedIcon, false);
imagesavealpha($resizedIcon, true);
$transparent = imagecolorallocatealpha($resizedIcon, 0, 0, 0, 127);
imagefill($resizedIcon, 0, 0, $transparent);
imagealphablending($icon, true);
imagecopyresampled($resizedIcon, $icon, 0, 0, 0, 0, $targetWidth, $targetHeight, $iconWidth, $iconHeight);
$x = (int) (($width - $targetWidth) / 2);
imagecopy($canvas, $resizedIcon, $x, $margin, 0, 0, $targetWidth, $targetHeight);
imagecopy($canvas, $source, 0, $topPadding, 0, 0, $width, $height);
$savers[$format]($canvas, $path);
imagedestroy($source);
imagedestroy($icon);
imagedestroy($resizedIcon);
imagedestroy($canvas);
}
public function getQrcode($id) { public function getQrcode($id) {
$db = getDbInstance(); $db = getDbInstance();
@@ -173,7 +267,12 @@ class Qrcode {
$this->failure($e->getMessage()); $this->failure($e->getMessage());
} }
$this->addFrameText($filename, $data_to_db['format'], $input_data['frame_text'] ?? ''); $this->addTopIcon($filename, $data_to_db['format'], $input_data['icon_tmp_path'] ?? null);
$this->addFrameText($filename, $data_to_db['format'], $input_data['frame_text'] ?? '', $input_data['frame_font'] ?? 'sans', $input_data['frame_font_size'] ?? 16);
if (!empty($input_data['icon_tmp_path'])) {
@unlink($input_data['icon_tmp_path']);
}
// If you want you can customi<e qr code with logo // If you want you can customi<e qr code with logo
//$this->addLogo($data_to_db['qrcode'], $options['optionlogo']); //$this->addLogo($data_to_db['qrcode'], $options['optionlogo']);
@@ -245,7 +344,12 @@ class Qrcode {
return ['ok' => false, 'error' => 'Could not write the qr code file.']; return ['ok' => false, 'error' => 'Could not write the qr code file.'];
} }
$this->addFrameText($path, $format, $input_data['frame_text'] ?? ''); $this->addTopIcon($path, $format, $input_data['icon_tmp_path'] ?? null);
$this->addFrameText($path, $format, $input_data['frame_text'] ?? '', $input_data['frame_font'] ?? 'sans', $input_data['frame_font_size'] ?? 16);
if (!empty($input_data['icon_tmp_path'])) {
@unlink($input_data['icon_tmp_path']);
}
$db = getDbInstance(); $db = getDbInstance();
$last_id = $db->insert($this->table, $data_to_db); $last_id = $db->insert($this->table, $data_to_db);
+38 -8
View File
@@ -330,21 +330,48 @@ class StaticQrcode {
/** /**
* create a qr code of type "bitcoin" * create a qr code of type "bitcoin"
* @string address -> required * @string address -> required
* @int amount -> required * @string amount -> optional (a bitcoin address is useful as a standing QR code,
* not just for one specific payment amount)
* @string label * @string label
* @string message * @string message
*/ */
public function bitcoinQrcode($address, $amount, $label, $message) public function bitcoinQrcode($address, $amount, $label, $message)
{ {
if($address != NULL && $amount != NULL){ $address = trim((string) $address);
$this->sData = 'bitcoin:'.$address.'?amount='.$amount.'&label='.$label.'&message='.$message; $amount = trim((string) $amount);
$this->sContent = '<strong>BTC address:</strong> '.$address.'<br>'.'<strong>Amount:</strong> '.$amount.'<br>'; $label = trim((string) $label);
$this->sContent .= '<strong>Label:</strong> '.$label.'<br>'.'<strong>Message:</strong> '.$message; $message = trim((string) $message);
$this->addQrcode("bitcoin"); if ($address === '') {
}
else
$this->requiredFieldsError(); $this->requiredFieldsError();
return;
}
$params = [];
if ($amount !== '') {
$params[] = 'amount=' . rawurlencode($amount);
}
if ($label !== '') {
$params[] = 'label=' . rawurlencode($label);
}
if ($message !== '') {
$params[] = 'message=' . rawurlencode($message);
}
$this->sData = 'bitcoin:' . $address . ($params ? '?' . implode('&', $params) : '');
$this->sContent = '<strong>BTC address:</strong> ' . $address . '<br>';
if ($amount !== '') {
$this->sContent .= '<strong>Amount:</strong> ' . $amount . '<br>';
}
if ($label !== '') {
$this->sContent .= '<strong>Label:</strong> ' . $label . '<br>';
}
if ($message !== '') {
$this->sContent .= '<strong>Message:</strong> ' . $message;
}
$this->addQrcode("bitcoin");
} }
/** /**
@@ -473,6 +500,9 @@ class StaticQrcode {
$input_data["foreground"] = $_POST['foreground']; $input_data["foreground"] = $_POST['foreground'];
$input_data["background"] = $_POST['background']; $input_data["background"] = $_POST['background'];
$input_data["frame_text"] = $_POST['frame_text'] ?? ''; $input_data["frame_text"] = $_POST['frame_text'] ?? '';
$input_data["frame_font"] = $_POST['frame_font'] ?? 'sans';
$input_data["frame_font_size"] = $_POST['frame_font_size'] ?? 16;
$input_data["icon_tmp_path"] = $_POST['icon_tmp_path'] ?? null;
$data_to_qrcode = urlencode($this->sData); $data_to_qrcode = urlencode($this->sData);
+9 -1
View File
@@ -34,7 +34,15 @@ if($_SERVER["REQUEST_METHOD"] === "POST" && isset($_POST["edit"])) {
$static_qrcode_instance->editQrcode($_POST); $static_qrcode_instance->editQrcode($_POST);
} }
if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"])) { if($_SERVER["REQUEST_METHOD"] === "POST" && !isset($_POST["edit"]) && !isset($_POST["del_id"])) {
$icon_upload = qr_handle_icon_upload('icon');
if (!$icon_upload['ok']) {
$_SESSION['failure'] = $icon_upload['error'];
header('Location: ' . basename(__FILE__) . '?type=' . urlencode($_GET['type'] ?? ''));
exit;
}
$_POST['icon_tmp_path'] = $icon_upload['path'];
switch($_GET['type']){ switch($_GET['type']){
case 'text': $static_qrcode_instance->textQrcode($_POST['text']); case 'text': $static_qrcode_instance->textQrcode($_POST['text']);
break; break;