+/*
+ * Ostromoukhov variable-coefficient error diffusion for a 16-level
+ * monochrome E Ink Carta display. Coefficients are from Appendix I of:
+ * V. Ostromoukhov, "A Simple and Efficient Error-Diffusion Algorithm",
+ * SIGGRAPH 2001. Multiple-level quantization follows the paper's stated
+ * extension of the algorithm.
+ */
+(function (global) {
+ 'use strict';
+
+ var LEVEL_COUNT = 16;
+ var LEVEL_STEP = 255 / (LEVEL_COUNT - 1);
+ // ED103TC2 active-matrix resolution (approximately 227 ppi).
+ var PANEL_LONG_EDGE = 1872;
+ var PANEL_SHORT_EDGE = 1404;
+ var MAX_CANVAS_PIXELS = PANEL_LONG_EDGE * PANEL_SHORT_EDGE;
+
+ // A10 (next), A-11 (next row opposite the scan), and A01 (next row).
+ // The paper defines entries 128..255 by reflection around 127.5.
+ var coefficientNumerators = new Uint16Array([
+ 13,0,5, 13,0,5, 21,0,10, 7,0,4, 8,0,5, 47,3,28, 23,3,13, 15,3,8,
+ 22,6,11, 43,15,20, 7,3,3, 501,224,211, 249,116,103, 165,80,67, 123,62,49, 489,256,191,
+ 81,44,31, 483,272,181, 60,35,22, 53,32,19, 237,148,83, 471,304,161, 3,2,1, 459,304,161,
+ 38,25,14, 453,296,175, 225,146,91, 149,96,63, 111,71,49, 63,40,29, 73,46,35, 435,272,217,
+ 108,67,56, 13,8,7, 213,130,119, 423,256,245, 5,3,3, 281,173,162, 141,89,78, 283,183,150,
+ 71,47,36, 285,193,138, 13,9,6, 41,29,18, 36,26,15, 289,213,114, 145,109,54, 291,223,102,
+ 73,57,24, 293,233,90, 21,17,6, 295,243,78, 37,31,9, 27,23,6, 149,129,30, 299,263,54,
+ 75,67,12, 43,39,6, 151,139,18, 303,283,30, 38,36,3, 305,293,18, 153,149,6, 307,303,6,
+ 1,1,0, 101,105,2, 49,53,2, 95,107,6, 23,27,2, 89,109,10, 43,55,6, 83,111,14,
+ 5,7,1, 172,181,37, 97,76,22, 72,41,17, 119,47,29, 4,1,1, 4,1,1, 4,1,1,
+ 4,1,1, 4,1,1, 4,1,1, 4,1,1, 4,1,1, 4,1,1, 65,18,17, 95,29,26,
+ 185,62,53, 30,11,9, 35,14,11, 85,37,28, 55,26,19, 80,41,29, 155,86,59, 5,3,2,
+ 5,3,2, 5,3,2, 5,3,2, 5,3,2, 5,3,2, 5,3,2, 5,3,2, 5,3,2,
+ 5,3,2, 5,3,2, 5,3,2, 5,3,2, 305,176,119, 155,86,59, 105,56,39, 80,41,29,
+ 65,32,23, 55,26,19, 335,152,113, 85,37,28, 115,48,37, 35,14,11, 355,136,109, 30,11,9,
+ 365,128,107, 185,62,53, 25,8,7, 95,29,26, 385,112,103, 65,18,17, 395,104,101, 4,1,1
+ ]);
+
+ function buildCoefficientWeights() {
+ var weights = new Float32Array(256 * 3);
+ for (var value = 0; value < 256; value++) {
+ var reflected = value < 128 ? value : 255 - value;
+ var source = reflected * 3;
+ var destination = value * 3;
+ var sum = coefficientNumerators[source] + coefficientNumerators[source + 1] + coefficientNumerators[source + 2];
+ weights[destination] = coefficientNumerators[source] / sum;
+ weights[destination + 1] = coefficientNumerators[source + 1] / sum;
+ weights[destination + 2] = coefficientNumerators[source + 2] / sum;
+ }
+ return weights;
+ }
+
+ function buildCoefficientOffsets() {
+ var offsets = new Uint16Array(256);
+ for (var value = 0; value < 256; value++) {
+ offsets[value] = Math.round(((value % LEVEL_STEP) / LEVEL_STEP) * 255) * 3;
+ }
+ return offsets;
+ }
+
+ var coefficientWeights = buildCoefficientWeights();
+ var coefficientOffsets = buildCoefficientOffsets();
+
+ function clamp(value, minimum, maximum) {
+ return value < minimum ? minimum : value > maximum ? maximum : value;
+ }
+
+ function ditherOstromoukhov(imageData) {
+ var width = imageData.width | 0;
+ var height = imageData.height | 0;
+ var data = imageData.data;
+ if (width <= 0 || height <= 0 || !data || data.length < width * height * 4) return imageData;
+
+ var currentErrors = new Float32Array(width + 2);
+ var nextErrors = new Float32Array(width + 2);
+ for (var y = 0; y < height; y++) {
+ var direction = (y & 1) === 0 ? 1 : -1;
+ var start = direction > 0 ? 0 : width - 1;
+ var stop = direction > 0 ? width : -1;
+
+ for (var x = start; x !== stop; x += direction) {
+ var pixel = (y * width + x) * 4;
+ if (data[pixel + 3] === 0) {
+ currentErrors[x + 1] = 0;
+ continue;
+ }
+
+ // Rec. 709 luma retains the source's encoded tonal scale. The panel
+ // calibration is handled by Android's display pipeline.
+ var sourceGray = data[pixel] * 0.2126 + data[pixel + 1] * 0.7152 + data[pixel + 2] * 0.0722;
+ var corrected = sourceGray + currentErrors[x + 1];
+ var outputLevel = clamp(Math.round(corrected / LEVEL_STEP), 0, LEVEL_COUNT - 1);
+ var outputGray = Math.round(outputLevel * LEVEL_STEP);
+ data[pixel] = outputGray;
+ data[pixel + 1] = outputGray;
+ data[pixel + 2] = outputGray;
+
+ var error = corrected - outputGray;
+ if (error === 0) continue;
+ // Each pair of adjacent panel grays is a local bilevel interval.
+ // Normalize that interval before selecting Ostromoukhov's weights.
+ var coefficient = coefficientOffsets[clamp(Math.round(sourceGray), 0, 255)];
+ var nextX = x + direction;
+ var oppositeX = x - direction;
+
+ if (nextX >= 0 && nextX < width && data[(y * width + nextX) * 4 + 3] !== 0) {
+ currentErrors[nextX + 1] += error * coefficientWeights[coefficient];
+ }
+ if (y + 1 < height) {
+ if (oppositeX >= 0 && oppositeX < width && data[((y + 1) * width + oppositeX) * 4 + 3] !== 0) {
+ nextErrors[oppositeX + 1] += error * coefficientWeights[coefficient + 1];
+ }
+ if (data[((y + 1) * width + x) * 4 + 3] !== 0) {
+ nextErrors[x + 1] += error * coefficientWeights[coefficient + 2];
+ }
+ }
+ }
+
+ var previousErrors = currentErrors;
+ currentErrors = nextErrors;
+ nextErrors = previousErrors;
+ nextErrors.fill(0);
+ }
+ return imageData;
+ }
+
+ function workerMessage(event) {
+ var message = event.data;
+ var data = new Uint8ClampedArray(message.buffer);
+ ditherOstromoukhov({width: message.width, height: message.height, data: data});
+ self.postMessage({id: message.id, width: message.width, height: message.height, buffer: data.buffer}, [data.buffer]);
+ }
+
+ var worker = null;
+ var workerFailed = false;
+ var objectUrls = [];
+ var nextJobId = 1;
+ var jobs = Object.create(null);
+
+ function createWorker() {
+ if (worker || workerFailed || typeof Worker === 'undefined' || typeof Blob === 'undefined') return worker;
+ try {
+ var source = "'use strict';" +
+ 'var LEVEL_COUNT=' + LEVEL_COUNT + ',LEVEL_STEP=' + LEVEL_STEP + ';' +
+ 'var coefficientNumerators=new Uint16Array(' + JSON.stringify(Array.prototype.slice.call(coefficientNumerators)) + ');' +
+ buildCoefficientWeights.toString() + ';' +
+ buildCoefficientOffsets.toString() + ';' +
+ clamp.toString() + ';' +
+ ditherOstromoukhov.toString() + ';' +
+ 'var coefficientWeights=buildCoefficientWeights(),coefficientOffsets=buildCoefficientOffsets();' +
+ 'self.onmessage=' + workerMessage.toString() + ';';
+ var url = URL.createObjectURL(new Blob([source], {type: 'text/javascript'}));
+ worker = new Worker(url);
+ objectUrls.push(url);
+ worker.onmessage = function (event) {
+ var job = jobs[event.data.id];
+ if (!job) return;
+ delete jobs[event.data.id];
+ job.resolve(new ImageData(new Uint8ClampedArray(event.data.buffer), event.data.width, event.data.height));
+ };
+ worker.onerror = function (event) {
+ workerFailed = true;
+ var detail = event && event.message ? ': ' + event.message + ' (' + event.filename + ':' + event.lineno + ')' : '';
+ var failure = new Error('The image dither worker failed' + detail + '.');
+ Object.keys(jobs).forEach(function (id) { jobs[id].reject(failure); delete jobs[id]; });
+ worker.terminate();
+ worker = null;
+ };
+ } catch (_) {
+ workerFailed = true;
+ worker = null;
+ }
+ return worker;
+ }
+
+ function ditherAsync(imageData) {
+ var activeWorker = createWorker();
+ if (!activeWorker) return Promise.resolve(ditherOstromoukhov(imageData));
+ return new Promise(function (resolve, reject) {
+ var id = nextJobId++;
+ jobs[id] = {resolve: resolve, reject: reject};
+ activeWorker.postMessage({id: id, width: imageData.width, height: imageData.height, buffer: imageData.data.buffer}, [imageData.data.buffer]);
+ });
+ }
+
+ function fittedSize(width, height) {
+ var longEdge = Math.max(width, height);
+ var shortEdge = Math.min(width, height);
+ var scale = Math.min(1, PANEL_LONG_EDGE / longEdge, PANEL_SHORT_EDGE / shortEdge);
+ return {
+ width: Math.max(1, Math.round(width * scale)),
+ height: Math.max(1, Math.round(height * scale))
+ };
+ }
+
+ function imageTargetSize(image, source) {
+ var bounds = image.getBoundingClientRect();
+ if (bounds.width <= 0 || bounds.height <= 0) return fittedSize(source.naturalWidth, source.naturalHeight);
+ var pixelRatio = Math.max(1, Number(global.devicePixelRatio) || 1);
+ var width = bounds.width * pixelRatio;
+ var height = bounds.height * pixelRatio;
+ var sourceRatio = source.naturalWidth / source.naturalHeight;
+ var boxRatio = width / height;
+ var svgContains = elementName(image) === 'image' && !/^\s*none(?:\s|$)/i.test(image.getAttribute('preserveAspectRatio') || 'xMidYMid meet');
+ if (Math.abs(sourceRatio - boxRatio) > 0.01 && (getComputedStyle(image).objectFit === 'contain' || svgContains)) {
+ var containScale = Math.min(width / source.naturalWidth, height / source.naturalHeight);
+ width = source.naturalWidth * containScale;
+ height = source.naturalHeight * containScale;
+ }
+ return fittedSize(width, height);
+ }
+
+ function canvasContext(canvas) {
+ return canvas.getContext('2d', {alpha: true, willReadFrequently: true});
+ }
+
+ function ditherContext(context, width, height) {
+ return ditherAsync(context.getImageData(0, 0, width, height)).catch(function () {
+ // A worker can be blocked by an older WebView policy after construction.
+ // Redraw from the still-intact canvas and complete the same algorithm here.
+ return ditherOstromoukhov(context.getImageData(0, 0, width, height));
+ });
+ }
+
+ function imageReady(image) {
+ if (image.complete) return Promise.resolve();
+ return new Promise(function (resolve, reject) {
+ image.addEventListener('load', resolve, {once: true});
+ image.addEventListener('error', function () { reject(new Error('The image could not be decoded.')); }, {once: true});
+ });
+ }
+
+ function elementName(element) {
+ return String(element.localName || element.tagName || '').toLowerCase();
+ }
+
+ function imageSourceUrl(image) {
+ if (elementName(image) === 'image') {
+ return String(image.getAttribute('href') || image.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || '');
+ }
+ return String(image.currentSrc || image.src || '');
+ }
+
+ function canvasImage(image) {
+ var sourceUrl = imageSourceUrl(image);
+ if (elementName(image) === 'img' && sourceUrl.indexOf('https://alexandria.invalid/content/') !== 0) {
+ return Promise.resolve(image);
+ }
+ return new Promise(function (resolve, reject) {
+ var copy = new Image();
+ if (sourceUrl.indexOf('https://alexandria.invalid/content/') === 0) copy.crossOrigin = 'anonymous';
+ copy.addEventListener('load', function () { resolve(copy); }, {once: true});
+ copy.addEventListener('error', function () { reject(new Error('The image could not be loaded for dithering.')); }, {once: true});
+ copy.src = sourceUrl;
+ });
+ }
+
+ function canvasBlob(canvas) {
+ return new Promise(function (resolve, reject) {
+ canvas.toBlob(function (blob) {
+ if (blob) resolve(blob); else reject(new Error('The dithered image could not be encoded.'));
+ }, 'image/png');
+ });
+ }
+
+ function objectUrl(blob) {
+ var url = URL.createObjectURL(blob);
+ objectUrls.push(url);
+ return url;
+ }
+
+ function replaceImageSource(image, blob) {
+ return new Promise(function (resolve, reject) {
+ var url = objectUrl(blob);
+ var loaded = function () { cleanup(); resolve(); };
+ var failed = function () { cleanup(); reject(new Error('The dithered image could not be displayed.')); };
+ var cleanup = function () {
+ image.removeEventListener('load', loaded);
+ image.removeEventListener('error', failed);
+ };
+ image.addEventListener('load', loaded);
+ image.addEventListener('error', failed);
+ image.srcset = url;
+ if (image.parentElement && image.parentElement.tagName === 'PICTURE') {
+ Array.prototype.forEach.call(image.parentElement.querySelectorAll('source'), function (source) {
+ source.removeAttribute('type');
+ source.srcset = url;
+ });
+ }
+ image.src = url;
+ });
+ }
+
+ function replaceSvgImageSource(image, blob) {
+ var url = objectUrl(blob);
+ image.setAttribute('href', url);
+ image.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', url);
+ // Older Android WebViews do not always repaint an SVG image when only its
+ // href changes. Reinsert the same node to invalidate that resource frame.
+ var parent = image.parentNode;
+ if (parent) {
+ var next = image.nextSibling;
+ parent.removeChild(image);
+ parent.insertBefore(image, next);
+ }
+ return new Promise(function (resolve) {
+ var schedule = global.requestAnimationFrame || function (callback) { global.setTimeout(callback, 0); };
+ schedule(function () { schedule(resolve); });
+ });
+ }
+
+ function isGif(image) {
+ var source = imageSourceUrl(image).toLowerCase();
+ return source.indexOf('data:image/gif') === 0 || /\.gif(?:$|[?#])/.test(source);
+ }
+
+ function ditherImageSource(image, source) {
+ if (!source.naturalWidth || !source.naturalHeight) throw new Error('The image has no pixels.');
+ var size = imageTargetSize(image, source);
+ var canvas = document.createElement('canvas');
+ canvas.width = size.width;
+ canvas.height = size.height;
+ var context = canvasContext(canvas);
+ if (!context) throw new Error('Canvas rendering is not available.');
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = 'high';
+ context.drawImage(source, 0, 0, size.width, size.height);
+ return ditherContext(context, size.width, size.height).then(function (result) {
+ context.putImageData(result, 0, 0);
+ return canvasBlob(canvas);
+ });
+ }
+
+ function processImage(image) {
+ if (isGif(image)) throw new Error('Animated GIFs use the display-safe fallback filter.');
+ return imageReady(image).then(function () { return canvasImage(image); }).then(function (source) {
+ return ditherImageSource(image, source);
+ }).then(function (blob) { return replaceImageSource(image, blob); });
+ }
+
+ function processSvgImage(image) {
+ if (isGif(image)) throw new Error('Animated GIFs use the display-safe fallback filter.');
+ return canvasImage(image).then(function (source) {
+ return ditherImageSource(image, source);
+ }).then(function (blob) { return replaceSvgImageSource(image, blob); });
+ }
+
+ function processCanvas(canvas) {
+ if (!canvas.width || !canvas.height || canvas.width * canvas.height > MAX_CANVAS_PIXELS) {
+ throw new Error('The canvas is too large for interactive dithering.');
+ }
+ var context = canvasContext(canvas);
+ if (!context) throw new Error('Canvas rendering is not available.');
+ return ditherContext(context, canvas.width, canvas.height).then(function (result) {
+ context.putImageData(result, 0, 0);
+ });
+ }
+
+ var pendingSvgCounts = new WeakMap();
+
+ function beginPending(element) {
+ element.classList.add('alex-dither-pending');
+ if (elementName(element) === 'image' && element.ownerSVGElement) {
+ var svg = element.ownerSVGElement;
+ pendingSvgCounts.set(svg, (pendingSvgCounts.get(svg) || 0) + 1);
+ svg.classList.add('alex-dither-pending');
+ }
+ }
+
+ function endPending(element) {
+ element.classList.remove('alex-dither-pending');
+ if (elementName(element) === 'image' && element.ownerSVGElement) {
+ var svg = element.ownerSVGElement;
+ var remaining = Math.max(0, (pendingSvgCounts.get(svg) || 1) - 1);
+ if (remaining) pendingSvgCounts.set(svg, remaining);
+ else { pendingSvgCounts.delete(svg); svg.classList.remove('alex-dither-pending'); }
+ }
+ }
+
+ function ensureFallbackFilter() {
+ var filter = document.getElementById('alex-image-dither');
+ if (!filter || filter.firstChild) return filter;
+ var namespace = 'http://www.w3.org/2000/svg';
+ var gray = document.createElementNS(namespace, 'feColorMatrix');
+ gray.setAttribute('type', 'matrix');
+ gray.setAttribute('result', 'alex-gray');
+ gray.setAttribute('values', '0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0');
+ filter.appendChild(gray);
+ var transfer = document.createElementNS(namespace, 'feComponentTransfer');
+ transfer.setAttribute('in', 'alex-gray');
+ var levels = [];
+ for (var level = 0; level < LEVEL_COUNT; level++) levels.push((level / (LEVEL_COUNT - 1)).toFixed(4));
+ ['R', 'G', 'B'].forEach(function (channel) {
+ var component = document.createElementNS(namespace, 'feFunc' + channel);
+ component.setAttribute('type', 'discrete');
+ component.setAttribute('tableValues', levels.join(' '));
+ transfer.appendChild(component);
+ });
+ filter.appendChild(transfer);
+ return filter;
+ }
+
+ function applyFallback(element) {
+ if (!ensureFallbackFilter()) return;
+ var original = element.style.filter || '';
+ if (original.indexOf('alex-image-dither') >= 0) return;
+ element.classList.add('alex-dither-fallback');
+ element.style.filter = original ? original + ' url(#alex-image-dither)' : 'url(#alex-image-dither)';
+ }
+
+ var states = new WeakMap();
+ var queue = [];
+ var processing = false;
+
+ function processNext() {
+ if (processing) return;
+ var element = queue.shift();
+ if (!element) return;
+ processing = true;
+ states.set(element, 'processing');
+ beginPending(element);
+ var work;
+ try {
+ var name = elementName(element);
+ work = name === 'img' ? processImage(element) : name === 'image' ? processSvgImage(element) : processCanvas(element);
+ } catch (error) {
+ work = Promise.reject(error);
+ }
+ Promise.resolve(work).then(function () {
+ endPending(element);
+ element.classList.add('alex-dithered');
+ if (elementName(element) === 'image' && element.ownerSVGElement) {
+ element.setAttribute('image-rendering', 'optimizeSpeed');
+ element.ownerSVGElement.setAttribute('image-rendering', 'optimizeSpeed');
+ element.ownerSVGElement.classList.add('alex-dithered-svg');
+ }
+ states.set(element, 'done');
+ }, function (error) {
+ endPending(element);
+ applyFallback(element);
+ states.set(element, 'fallback');
+ if (global.console && console.warn) console.warn('Image dithering used its fallback: ' + (error && error.message ? error.message : error));
+ }).then(function () {
+ processing = false;
+ processNext();
+ });
+ }
+
+ function enqueue(element, priority) {
+ var state = states.get(element);
+ if (state === 'queued' || state === 'processing' || state === 'done' || state === 'fallback') return;
+ states.set(element, 'queued');
+ if (priority) queue.unshift(element); else queue.push(element);
+ processNext();
+ }
+
+ function isNearViewport(element) {
+ var bounds = element.getBoundingClientRect();
+ var margin = Math.max(global.innerWidth || 0, global.innerHeight || 0);
+ return bounds.width > 0 && bounds.height > 0 && bounds.right >= -margin && bounds.bottom >= -margin && bounds.left <= (global.innerWidth || 0) + margin && bounds.top <= (global.innerHeight || 0) + margin;
+ }
+
+ var observer = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) { if (entry.isIntersecting) enqueue(entry.target, true); });
+ }, {rootMargin: '100%'});
+
+ function observe(element) {
+ var state = states.get(element);
+ if (state === 'fallback') { applyFallback(element); return; }
+ if (state === 'observed') { if (isNearViewport(element)) enqueue(element, true); return; }
+ if (state) return;
+ if ((elementName(element) === 'img' || elementName(element) === 'image') && isGif(element)) {
+ states.set(element, 'fallback');
+ applyFallback(element);
+ return;
+ }
+ states.set(element, 'observed');
+ if (isNearViewport(element) || !observer) enqueue(element, false); else observer.observe(element);
+ }
+
+ function elementsIncludingRoot(root, selector) {
+ var elements = Array.prototype.slice.call(root.querySelectorAll(selector));
+ if (root.matches && root.matches(selector)) elements.unshift(root);
+ return elements;
+ }
+
+ function apply(root) {
+ if (!root) return;
+ elementsIncludingRoot(root, 'img,canvas,svg image').forEach(observe);
+ elementsIncludingRoot(root, 'video,.alex-chapter svg').filter(function (svg) {
+ return elementName(svg) !== 'svg' || !svg.querySelector('image');
+ }).forEach(applyFallback);
+ }
+
+ global.addEventListener('pagehide', function () {
+ if (observer) observer.disconnect();
+ if (worker) worker.terminate();
+ objectUrls.forEach(function (url) { URL.revokeObjectURL(url); });
+ objectUrls = [];
+ }, {once: true});
+
+ global.AlexDither = Object.freeze({
+ apply: apply,
+ ditherImageData: ditherOstromoukhov,
+ configuration: Object.freeze({algorithm: 'ostromoukhov', levels: LEVEL_COUNT, serpentine: true, panel: 'Carta 1000'})
+ });
+})(window);