Bug 23566: Continue on device - with QR codes

This patch adds the option to show a QR code on the OPAC bibliographic
detail page. The URL of the page is encoded in the image so that
scanning it will take the user to that page on their device. The feature
is controlled by a new system preference, OPACDetailQRCode, which is
disabled by default.

The QR Code is generated by a JavaScript library, "kjua"
(https://github.com/lrsjng/kjua), which has been added to the "About"
page in the staff client.

To test, apply the patch and run the database update. Rebuild the OPAC
CSS (https://wiki.koha-community.org/wiki/Working_with_SCSS_in_the_OPAC_and_staff_client).

 - In the staff client, go to Administration -> System preferences.
 - Locate the OPACDetailQRCode system preferences under OPAC ->
   Features. It should be disabled.
   - Enable the preference and switch to the OPAC.
 - Locate a title in the catalog and view the detail page.
   - In the sidebar menu there should be a "Send to device" link.
   - Clicking the link should display a QR Code.
   - Scan the code using a QR Code-capable device.
   - The URL should be correct.
 - Disable the system preference and confirm that the "Send to device"
   link no longer appears on the OPAC detail page.

Signed-off-by: Christopher Brannon <cbrannon@cdalibrary.org>
Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
This commit is contained in:
Owen Leonard 2019-09-25 17:10:46 +00:00 committed by Martin Renvoize
parent f2b8444267
commit 4c17151d74
Signed by: martin.renvoize
GPG key ID: 422B469130441A0F
15 changed files with 399 additions and 0 deletions

View file

@ -0,0 +1,11 @@
$DBversion = 'XXX'; # will be replaced by the RM
if( CheckVersion( $DBversion ) ) {
$dbh->do(q|
INSERT IGNORE INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `type` ) VALUES
('OPACDetailQRCode','0','','Enable the display of a QR Code on the OPAC detail page','YesNo');
|);
SetVersion( $DBversion );
print "Upgrade to $DBversion done (Bug 23566 - Add OPACDetailQRCode system preference)\n";
}

View file

@ -701,6 +701,9 @@
<h2>HC Sticky</h2>
<p><a href="http://somewebmedia.com/hc-sticky/">HC Sticky</a> by Some Web Media is a JavaScript library that makes any element on your page visible while you scroll, licensed under the <a href="https://github.com/somewebmedia/hc-sticky/blob/master/LICENSE">MIT license</a>.</p>
<h2>kjua</h2>
<p><a href="https://larsjung.de/kjua/">kjua</a> by Lars Jung is a JavaScript library that generates QR codes, licensed under the <a href="https://github.com/lrsjng/kjua/blob/master/README.md">MIT license</a>.</p>
</div>
<div id="translations">

View file

@ -402,6 +402,12 @@ OPAC:
yes: Show
no: "Don't show"
- patron images on the patron information page in the OPAC.
-
- pref: OPACDetailQRCode
choices:
yes: Enable
no: "Don't enable"
- the option to show a QR Code on the OPAC bibliographic detail page.
-
- pref: OPACFinesTab
choices:

View file

@ -259,6 +259,12 @@ a {
&.removefromlist {
@extend %initial_icon;
}
&.show_qrcode {
@extend %initial_icon;
background-position: 0 -1164px; /* QR Code */
padding-left: 35px;
}
}
h1 {
@ -3210,5 +3216,13 @@ $star-selected: #EDB867;
/* END jQuery Bar Rating plugin for star ratings */
#qrcode {
margin-left: 35px;
img,
canvas {
margin-top: 1em;
}
}
@import "responsive";

View file

@ -40,6 +40,14 @@
</li>
[% END %]
[% IF ( Koha.Preference('OPACDetailQRCode' ) ) %]
<li>
<a class="show_qrcode" href="#">Send to device</a>
<div id="qrcode" class="hidden"></div>
</li>
[% END %]
[% SET export_options = Koha.Preference('OpacExportOptions').split(',') %]
[% IF export_options.size %]
<li>

View file

@ -1392,6 +1392,9 @@
[% IF ( OpacStarRatings != 'disable' ) %][% Asset.js("lib/jquery/plugins/jquery.barrating.min.js") | $raw %][% END %]
[% IF ( OpacHighlightedWords ) %][% Asset.js("lib/jquery/plugins/jquery.highlight-3.js") | $raw %][% END %]
[% IF ( Koha.Preference('OPACDetailQRCode') ) %]
[% Asset.js("lib/kjua/kjua.min.js") | $raw %]
[% END %]
<script>
[% IF ( OpacHighlightedWords ) %]
@ -1420,6 +1423,30 @@
[% END %]
$(document).ready(function() {
[% IF ( Koha.Preference('OPACDetailQRCode') ) %]
var qrcode = kjua({
ecLevel: "H",
render: "canvas",
rounded: 100,
size: 150,
text: location.href,
});
if (qrcode) {
document.getElementById("qrcode").appendChild( qrcode );
}
$(".show_qrcode").on("click", function(){
var qrcodeImg = $("#qrcode");
if( qrcodeImg.hasClass("hidden") ){
qrcodeImg.removeClass("hidden");
} else {
qrcodeImg.addClass("hidden");
}
});
[% END %]
$('#bibliodescriptions').tabs();
$(".branch-info-tooltip-trigger").uitooltip({
position: { my: "left+15 center", at: "right center" },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View file

@ -0,0 +1,18 @@
const {create_canvas, canvas_to_img, dpr} = require('./lib/dom');
const defaults = require('./lib/defaults');
const qrcode = require('./lib/qrcode');
const draw = require('./lib/draw');
module.exports = options => {
const settings = Object.assign({}, defaults, options);
const qr = qrcode(settings.text, settings.ecLevel, settings.minVersion, settings.quiet);
const ratio = settings.ratio || dpr;
const canvas = create_canvas(settings.size, ratio);
const context = canvas.getContext('2d');
context.scale(ratio, ratio);
draw(qr, context, settings);
return settings.render === 'image' ? canvas_to_img(canvas) : canvas;
};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,50 @@
module.exports = {
// render method: 'canvas' or 'image'
render: 'image',
// render pixel-perfect lines
crisp: true,
// minimum version: 1..40
minVersion: 1,
// error correction level: 'L', 'M', 'Q' or 'H'
ecLevel: 'L',
// size in pixel
size: 200,
// pixel-ratio, null for devicePixelRatio
ratio: null,
// code color
fill: '#333',
// background color
back: '#fff',
// content
text: 'no text',
// roundend corners in pc: 0..100
rounded: 0,
// quiet zone in modules
quiet: 0,
// modes: 'plain', 'label' or 'image'
mode: 'plain',
// label/image size and pos in pc: 0..100
mSize: 30,
mPosX: 50,
mPosY: 50,
// label
label: 'no label',
fontname: 'sans',
fontcolor: '#333',
// image element
image: null
};

View file

@ -0,0 +1,32 @@
const win = global.window;
const doc = win.document;
const dpr = win.devicePixelRatio || 1;
const create = name => doc.createElement(name);
const get_attr = (el, key) => el.getAttribute(key);
const set_attr = (el, key, value) => el.setAttribute(key, value);
const create_canvas = (size, ratio) => {
const canvas = create('canvas');
set_attr(canvas, 'width', size * ratio);
set_attr(canvas, 'height', size * ratio);
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
return canvas;
};
const canvas_to_img = canvas => {
const img = create('img');
set_attr(img, 'crossorigin', 'anonymous');
set_attr(img, 'src', canvas.toDataURL('image/png'));
set_attr(img, 'width', get_attr(canvas, 'width'));
set_attr(img, 'height', get_attr(canvas, 'height'));
img.style.width = canvas.style.width;
img.style.height = canvas.style.height;
return img;
};
module.exports = {
create_canvas,
canvas_to_img,
dpr
};

View file

@ -0,0 +1,48 @@
const draw_module_rounded = require('./draw_rounded');
const draw_mode = require('./draw_mode');
const draw_background = (ctx, settings) => {
ctx.fillStyle = settings.back;
ctx.fillRect(0, 0, settings.size, settings.size);
};
const draw_module_default = (qr, ctx, settings, width, row, col) => {
if (qr.isDark(row, col)) {
ctx.rect(col * width, row * width, width, width);
}
};
const draw_modules = (qr, ctx, settings) => {
if (!qr) {
return;
}
const draw_module = settings.rounded > 0 && settings.rounded <= 100 ? draw_module_rounded : draw_module_default;
const mod_count = qr.moduleCount;
let mod_size = settings.size / mod_count;
let offset = 0;
if (settings.crisp) {
mod_size = Math.floor(mod_size);
offset = Math.floor((settings.size - mod_size * mod_count) / 2);
}
ctx.translate(offset, offset);
ctx.beginPath();
for (let row = 0; row < mod_count; row += 1) {
for (let col = 0; col < mod_count; col += 1) {
draw_module(qr, ctx, settings, mod_size, row, col);
}
}
ctx.fillStyle = settings.fill;
ctx.fill();
ctx.translate(-offset, -offset);
};
const draw = (qr, ctx, settings) => {
draw_background(ctx, settings);
draw_modules(qr, ctx, settings);
draw_mode(ctx, settings);
};
module.exports = draw;

View file

@ -0,0 +1,47 @@
const draw_label = (ctx, settings) => {
const size = settings.size;
const font = 'bold ' + settings.mSize * 0.01 * size + 'px ' + settings.fontname;
ctx.strokeStyle = settings.back;
ctx.lineWidth = settings.mSize * 0.01 * size * 0.1;
ctx.fillStyle = settings.fontcolor;
ctx.font = font;
const w = ctx.measureText(settings.label).width;
const sh = settings.mSize * 0.01;
const sw = w / size;
const sl = (1 - sw) * settings.mPosX * 0.01;
const st = (1 - sh) * settings.mPosY * 0.01;
const x = sl * size;
const y = st * size + 0.75 * settings.mSize * 0.01 * size;
ctx.strokeText(settings.label, x, y);
ctx.fillText(settings.label, x, y);
};
const draw_image = (ctx, settings) => {
const size = settings.size;
const w = settings.image.naturalWidth || 1;
const h = settings.image.naturalHeight || 1;
const sh = settings.mSize * 0.01;
const sw = sh * w / h;
const sl = (1 - sw) * settings.mPosX * 0.01;
const st = (1 - sh) * settings.mPosY * 0.01;
const x = sl * size;
const y = st * size;
const iw = sw * size;
const ih = sh * size;
ctx.drawImage(settings.image, x, y, iw, ih);
};
const draw_mode = (ctx, settings) => {
const mode = settings.mode;
if (mode === 'label') {
draw_label(ctx, settings);
} else if (mode === 'image') {
draw_image(ctx, settings);
}
};
module.exports = draw_mode;

View file

@ -0,0 +1,91 @@
const wrap_ctx = ctx => {
return {
c: ctx,
m(...args) {this.c.moveTo(...args); return this;},
l(...args) {this.c.lineTo(...args); return this;},
a(...args) {this.c.arcTo(...args); return this;}
};
};
const draw_dark = (ctx, l, t, r, b, rad, nw, ne, se, sw) => {
if (nw) {
ctx.m(l + rad, t);
} else {
ctx.m(l, t);
}
if (ne) {
ctx.l(r - rad, t).a(r, t, r, b, rad);
} else {
ctx.l(r, t);
}
if (se) {
ctx.l(r, b - rad).a(r, b, l, b, rad);
} else {
ctx.l(r, b);
}
if (sw) {
ctx.l(l + rad, b).a(l, b, l, t, rad);
} else {
ctx.l(l, b);
}
if (nw) {
ctx.l(l, t + rad).a(l, t, r, t, rad);
} else {
ctx.l(l, t);
}
};
const draw_light = (ctx, l, t, r, b, rad, nw, ne, se, sw) => {
if (nw) {
ctx.m(l + rad, t).l(l, t).l(l, t + rad).a(l, t, l + rad, t, rad);
}
if (ne) {
ctx.m(r - rad, t).l(r, t).l(r, t + rad).a(r, t, r - rad, t, rad);
}
if (se) {
ctx.m(r - rad, b).l(r, b).l(r, b - rad).a(r, b, r - rad, b, rad);
}
if (sw) {
ctx.m(l + rad, b).l(l, b).l(l, b - rad).a(l, b, l + rad, b, rad);
}
};
const draw_mod = (qr, ctx, settings, width, row, col) => {
const left = col * width;
const top = row * width;
const right = left + width;
const bottom = top + width;
const radius = settings.rounded * 0.005 * width;
const isDark = qr.isDark;
const rowT = row - 1;
const rowB = row + 1;
const colL = col - 1;
const colR = col + 1;
const dC = isDark(row, col);
const dNW = isDark(rowT, colL);
const dN = isDark(rowT, col);
const dNE = isDark(rowT, colR);
const dE = isDark(row, colR);
const dSE = isDark(rowB, colR);
const dS = isDark(rowB, col);
const dSW = isDark(rowB, colL);
const dW = isDark(row, colL);
ctx = wrap_ctx(ctx);
if (dC) {
draw_dark(ctx, left, top, right, bottom, radius, !dN && !dW, !dN && !dE, !dS && !dE, !dS && !dW);
} else {
draw_light(ctx, left, top, right, bottom, radius, dN && dW && dNW, dN && dE && dNE, dS && dE && dSE, dS && dW && dSW);
}
};
module.exports = draw_mod;

View file

@ -0,0 +1,42 @@
const RE_CODE_LENGTH_OVERFLOW = /code length overflow/i;
const qr_gen = require('qrcode-generator');
qr_gen.stringToBytes = qr_gen.stringToBytesFuncs['UTF-8'];
const min_qrcode = (text, level, min_ver = 1) => {
min_ver = Math.max(1, min_ver);
for (let version = min_ver; version <= 40; version += 1) {
try {
const qr = qr_gen(version, level);
qr.addData(text);
qr.make();
const moduleCount = qr.getModuleCount();
const isDark = (row, col) => {
return row >= 0 &&
row < moduleCount &&
col >= 0 &&
col < moduleCount &&
qr.isDark(row, col);
};
return {text, level, version, moduleCount, isDark};
} catch (err) {
if (!(version < 40 && RE_CODE_LENGTH_OVERFLOW.test(err))) {
throw new Error(err);
}
}
}
return null;
};
const quiet_qrcode = (text = '', level = 'L', min_ver = 1, quiet = 0) => {
const qr = min_qrcode(text, level, min_ver);
if (qr) {
const prev_is_dark = qr.isDark;
qr.moduleCount += 2 * quiet;
qr.isDark = (row, col) => prev_is_dark(row - quiet, col - quiet);
}
return qr;
};
module.exports = quiet_qrcode;