Skip to content

Commit 2330e1f

Browse files
authored
feat(SLB-458): branded preview qr (#1565)
1 parent 9dc44d0 commit 2330e1f

File tree

8 files changed

+193
-17
lines changed

8 files changed

+193
-17
lines changed

packages/composer/amazeelabs/silverback_preview_link/silverback_preview_link.module

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,9 @@ function silverback_preview_link_theme(array $existing, string $type, string $th
5050
'variables' => [
5151
'title' => NULL,
5252
'preview_url' => NULL,
53-
'preview_qr_code' => NULL,
54-
'preview_qr_code_alt' => NULL,
55-
'link_description' => NULL,
56-
'actions_description' => NULL,
57-
'remaining_lifetime' => NULL,
53+
'preview_qr_code_url' => NULL,
54+
'expiry_description' => NULL,
55+
'actions_description' => NULL
5856
],
5957
],
6058
];

packages/composer/amazeelabs/silverback_preview_link/silverback_preview_link.routing.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ silverback_preview_link.preview.access:
2020
_auth: ['oauth2']
2121
no_cache: TRUE
2222

23+
silverback_preview_link.qr_code:
24+
path: '/preview/qr-code/{base64_url}'
25+
defaults:
26+
_controller: '\Drupal\silverback_preview_link\Controller\PreviewController::getQRCode'
27+
requirements:
28+
# Keep it very generic, it's just to prevent anonymous access / bots.
29+
_permission: 'access administration pages'
30+
2331
silverback_preview_link.preview_link.access:
2432
path: '/preview/link-access'
2533
defaults:

packages/composer/amazeelabs/silverback_preview_link/src/Controller/PreviewController.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
namespace Drupal\silverback_preview_link\Controller;
44

5+
use Drupal\Core\Cache\CacheableResponse;
56
use Drupal\Core\Controller\ControllerBase;
67
use Drupal\Core\Entity\ContentEntityInterface;
8+
use Drupal\silverback_preview_link\QRCodeWithLogo;
79
use Drupal\user\Entity\User;
810
use Symfony\Component\HttpFoundation\JsonResponse;
911

@@ -61,4 +63,14 @@ public function hasLinkAccess() {
6163
], 403);
6264
}
6365

66+
/**
67+
* Returns the QR SVG file.
68+
*/
69+
public function getQRCode(string $base64_url): CacheableResponse {
70+
$decodedUrl = base64_decode($base64_url);
71+
$qrCode = new QRCodeWithLogo();
72+
$result = $qrCode->getQRCode($decodedUrl);
73+
return new CacheableResponse($result, 200, ['Content-Type' => 'image/svg+xml']);
74+
}
75+
6476
}

packages/composer/amazeelabs/silverback_preview_link/src/Form/PreviewLinkForm.php

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Drupal\silverback_preview_link\Form;
66

77
use chillerlan\QRCode\QRCode;
8+
use chillerlan\QRCode\QROptions;
89
use Drupal\Component\Datetime\TimeInterface;
910
use Drupal\Component\Utility\Html;
1011
use Drupal\Component\Utility\NestedArray;
@@ -19,12 +20,15 @@
1920
use Drupal\Core\Form\FormStateInterface;
2021
use Drupal\Core\Messenger\MessengerInterface;
2122
use Drupal\Core\Routing\RouteMatchInterface;
23+
use Drupal\Core\Url;
2224
use Drupal\node\NodeInterface;
2325
use Drupal\silverback_preview_link\Entity\SilverbackPreviewLink;
2426
use Drupal\silverback_preview_link\PreviewLinkExpiry;
2527
use Drupal\silverback_preview_link\PreviewLinkHostInterface;
2628
use Drupal\silverback_preview_link\PreviewLinkStorageInterface;
2729
use Symfony\Component\DependencyInjection\ContainerInterface;
30+
use Drupal\silverback_preview_link\QRCodeLogo;
31+
use Drupal\silverback_preview_link\QRCodeWithLogo;
2832

2933
/**
3034
* Preview link form.
@@ -145,7 +149,8 @@ public function buildForm(array $form, FormStateInterface $form_state, RouteMatc
145149
$remainingSeconds = max(0, ($this->entity->getExpiry()?->getTimestamp() ?? 0) - $this->time->getRequestTime());
146150
$remainingAgeFormatted = $this->dateFormatter->formatInterval($remainingSeconds);
147151
$isNewToken = $this->linkExpiry->getLifetime() === $remainingSeconds;
148-
$qrCode = NULL;
152+
$displayQRCode = TRUE;
153+
$qrCodeUrlString = NULL;
149154
$actionsDescription = NULL;
150155

151156
if ($isNewToken) {
@@ -160,28 +165,31 @@ public function buildForm(array $form, FormStateInterface $form_state, RouteMatc
160165
':url' => $externalPreviewUrlString,
161166
'@entity_label' => $host->label(),
162167
]);
168+
$displayQRCode = FALSE;
163169
}
164170
else {
165171
$expiryDescription = $this->t('Live preview link for <em>@entity_label</em> expires in @lifetime.</p>', [
166172
':url' => $externalPreviewUrlString,
167173
'@entity_label' => $host->label(),
168174
'@lifetime' => $remainingAgeFormatted,
169175
]);
170-
$qrCode = (new QRCode)->render($externalPreviewUrlString);
171176
}
172-
$actionsDescription = $this->t('If a new link is generated, active preview link will get invalidated.');
177+
$actionsDescription = $this->t('If a new link is generated, the active link becomes invalid.');
178+
}
179+
180+
if ($displayQRCode) {
181+
$qrCodeEncodedUrl = base64_encode($externalPreviewUrlString);
182+
$qrCodeUrlString = Url::fromRoute('silverback_preview_link.qr_code', ['base64_url' => $qrCodeEncodedUrl])->toString();
173183
}
174184

175185
$form['preview_link'] = [
176186
'#theme' => 'preview_link',
177187
'#title' => $this->t('Preview link'),
178188
'#weight' => -9999,
179-
'#preview_qr_code' => $qrCode,
180-
'#preview_qr_alt' => $externalPreviewUrlString,
189+
'#preview_url' => $externalPreviewUrlString,
190+
'#preview_qr_code_url' => $qrCodeUrlString,
181191
'#expiry_description' => $expiryDescription,
182192
'#actions_description' => $actionsDescription,
183-
'#remaining_lifetime' => $remainingAgeFormatted,
184-
'#preview_url' => $externalPreviewUrlString,
185193
];
186194

187195
if (!$isNewToken) {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace Drupal\silverback_preview_link;
4+
5+
use Drupal\Tests\Component\Annotation\Doctrine\Fixtures\Annotation\Version;
6+
use chillerlan\QRCode\{QRCode, QRCodeException, QROptions};
7+
use chillerlan\QRCode\Data\QRMatrix;
8+
use chillerlan\QRCode\Common\EccLevel;
9+
use Symfony\Component\HttpFoundation\Response;
10+
use function file_exists, gzencode, header, is_readable, max, min;
11+
12+
/**
13+
* Creates and renders a QR Code with embedded SVG logo.
14+
*/
15+
class QRCodeWithLogo {
16+
17+
private $config = [
18+
'svgLogo' => __DIR__ . '/images/amazee-labs_logo-square-green.svg',
19+
'svgLogoScale' => 1,
20+
'svgLogoCssClass' => 'dark',
21+
'version' => QRCode::VERSION_AUTO,
22+
'outputType' => QRCode::OUTPUT_CUSTOM,
23+
'outputInterface' => QRMarkupSVGWithLogo::class,
24+
'imageBase64' => FALSE,
25+
// ECC level H is necessary when using logos.
26+
'eccLevel' => EccLevel::H,
27+
'addQuietzone' => TRUE,
28+
// If set to TRUE, the light modules won't be rendered.
29+
'imageTransparent' => FALSE,
30+
// Empty the default value to remove the fill* attributes from the <path> elements
31+
'markupDark' => '',
32+
'markupLight' => '',
33+
'drawCircularModules' => TRUE,
34+
'circleRadius' => 0.45,
35+
'svgConnectPaths' => TRUE,
36+
'keepAsSquare' => [
37+
QRMatrix::M_FINDER | QRMatrix::IS_DARK,
38+
QRMatrix::M_FINDER_DOT,
39+
QRMatrix::M_ALIGNMENT | QRMatrix::IS_DARK,
40+
],
41+
// https://developer.mozilla.org/en-US/docs/Web/SVG/Element/linearGradient
42+
'svgDefs' => '
43+
<linearGradient id="gradient" x1="100%" y2="100%">
44+
<stop stop-color="#951b81" offset="0"/>
45+
<stop stop-color="#00a29a" offset="0.8"/>
46+
</linearGradient>
47+
<style><![CDATA[
48+
.dark{fill: url(#gradient);}
49+
.light{fill: #fff;}
50+
]]></style>',
51+
];
52+
53+
private function getOptions(): QROptions {
54+
// Augment the QROptions class.
55+
return new class ($this->config) extends QROptions {
56+
57+
protected string $svgLogo;
58+
59+
// Logo scale in % of QR Code size, clamped to 10%-30%.
60+
protected float $svgLogoScale = 0.20;
61+
62+
// CSS class for the logo (defined in $svgDefs).
63+
protected string $svgLogoCssClass = '';
64+
65+
protected function set_svgLogo(string $svgLogo): void {
66+
if (!file_exists($svgLogo) || !is_readable($svgLogo)) {
67+
throw new QRCodeException('invalid svg logo');
68+
}
69+
$this->svgLogo = $svgLogo;
70+
}
71+
72+
// Clamp logo scale.
73+
protected function set_svgLogoScale(float $svgLogoScale): void {
74+
$this->svgLogoScale = max(0.05, min(0.3, $svgLogoScale));
75+
}
76+
77+
};
78+
}
79+
80+
public function getQRCode(string $data) {
81+
return (new QRCode($this->getOptions()))->render($data);
82+
}
83+
84+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Drupal\silverback_preview_link;
4+
5+
use chillerlan\QRCode\Output\QRMarkupSVG;
6+
7+
/**
8+
* Output interface for QRCode::OUTPUT_CUSTOM.
9+
*/
10+
class QRMarkupSVGWithLogo extends QRMarkupSVG {
11+
12+
/**
13+
* {@inheritdoc}
14+
*/
15+
protected function paths(): string {
16+
$size = (int) ceil($this->moduleCount * $this->options->svgLogoScale);
17+
// Calling QRMatrix::setLogoSpace() manually,
18+
// so QROptions::$addLogoSpace has no effect.
19+
$this->matrix->setLogoSpace($size, $size);
20+
$svg = parent::paths();
21+
$svg .= $this->getLogo();
22+
return $svg;
23+
}
24+
25+
/**
26+
* {@inheritdoc}
27+
*/
28+
protected function path(string $path, int $M_TYPE): string {
29+
// Omit the "fill" and "opacity" attributes on the path element.
30+
return sprintf('<path class="%s" d="%s"/>', $this->getCssClass($M_TYPE), $path);
31+
}
32+
33+
/**
34+
* Returns a <g> element that contains the SVG logo and positions
35+
* it properly within the QR Code.
36+
*
37+
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g
38+
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform
39+
*/
40+
protected function getLogo(): string {
41+
return sprintf(
42+
'%5$s<g transform="translate(%1$s %1$s) scale(%2$s)" class="%3$s">%5$s%4$s%5$s</g>',
43+
(($this->moduleCount - ($this->moduleCount * $this->options->svgLogoScale)) / 2),
44+
$this->options->svgLogoScale,
45+
$this->options->svgLogoCssClass,
46+
file_get_contents($this->options->svgLogo),
47+
$this->options->eol
48+
);
49+
}
50+
51+
}
Lines changed: 13 additions & 0 deletions
Loading

packages/composer/amazeelabs/silverback_preview_link/templates/preview-link.html.twig

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,26 @@
22
<h2 class="preview-link__title hidden">{{ title }}</h2>
33
{% if preview_url is not empty %}
44
<div class="preview-link__copy js-form-item form-item js-form-type-textfield form-type--textfield">
5-
<input id="preview-link__copy-text"
5+
<input id="preview-link__copy--text"
66
disabled
7-
name="preview-link__copy-text"
7+
name="preview-link__copy--text"
88
type="text"
99
value="{{ preview_url }}"
1010
size="32"
1111
class="form-text required form-element form-element--type-text form-element--api-textfield"
1212
>
1313
<button class="button">{{ 'Copy' }}</button>
14-
<div id="preview-link__copy-result" class="form-item__description">
14+
<div id="preview-link__copy--result" class="form-item__description">
1515
&nbsp;
1616
</div>
1717
</div>
1818
{% endif %}
19-
{% if preview_qr_code is not empty %}
19+
{% if preview_qr_code_url is not empty %}
2020
<div class="preview-link__qr">
2121
<p>{{ 'Scan the QR Code to open the preview on another device.' }}</p>
22-
<img src="{{ preview_qr_code }}" alt="{{ preview_qr_alt }}" width="200" height="200" />
22+
<div class="preview-link__qr--wrapper" style="display: flex; align-items: center; justify-content: center;">
23+
<img src="{{ preview_qr_code_url }}" alt="{{ preview_url }}" width="300" height="300" />
24+
</div>
2325
</div>
2426
{% endif %}
2527
{% if expiry_description is not empty %}

0 commit comments

Comments
 (0)