import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
+import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
},
)
activity.setContentView(reader)
- reader.load(prepared.reader, prepared.publication.rootDirectory)
+ reader.load(prepared.reader, prepared.publication)
}
assertTrue("Reader bridge did not become ready", ready.await(10, TimeUnit.SECONDS))
}
}
+ @Test
+ fun appliesOstromoukhovDitheringToRasterImages() {
+ val prepared = prepareFixedPublication("dither")
+ val ready = CountDownLatch(1)
+ val layoutReady = CountDownLatch(1)
+ val webView = AtomicReference<ReaderWebView>()
+
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ val reader = ReaderWebView(activity)
+ webView.set(reader)
+ reader.listener = listener(
+ ready = {
+ reader.evaluateJavascript(
+ "(function(){var chapter=document.querySelector('.alex-chapter[data-spine=\\\"2\\\"]')," +
+ "source=chapter.querySelector('img').getAttribute('src'),svg=document.createElementNS('http://www.w3.org/2000/svg','svg')," +
+ "image=document.createElementNS('http://www.w3.org/2000/svg','image');svg.setAttribute('viewBox','0 0 40 40');" +
+ "svg.style.cssText='position:absolute;width:40px;height:40px';image.setAttribute('class','alex-svg-raster-test');" +
+ "image.setAttribute('width','40');image.setAttribute('height','40');image.setAttribute('href',source);" +
+ "svg.appendChild(image);chapter.appendChild(svg);})()",
+ ) {
+ ready.countDown()
+ reader.applySettings(
+ ReaderSettings(),
+ ReaderLocation(page = 2, totalPages = 4, chapter = 2, progress = 2f / 3f),
+ )
+ }
+ },
+ layoutReady = layoutReady::countDown,
+ )
+ activity.setContentView(reader)
+ reader.load(prepared.reader, prepared.publication)
+ }
+
+ assertTrue("Reader bridge did not become ready", ready.await(10, TimeUnit.SECONDS))
+ assertTrue("Fixed layout did not report completion", layoutReady.await(10, TimeUnit.SECONDS))
+ var result: JSONObject? = null
+ val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10)
+ while (System.nanoTime() < deadline) {
+ result = evaluateJson(
+ scenario,
+ webView.get(),
+ "(function(){var image=document.querySelector('.alex-current-page img')," +
+ "svgImage=document.querySelector('.alex-current-page .alex-svg-raster-test');" +
+ "return JSON.stringify({source:image?image.currentSrc:'',filter:image?image.style.filter:''," +
+ "svgSource:svgImage?svgImage.getAttribute('href'):'',svgFilter:svgImage&&svgImage.ownerSVGElement?svgImage.ownerSVGElement.style.filter:''," +
+ "configuration:window.AlexDither?AlexDither.configuration:null});})()",
+ )
+ if (result?.optString("source")?.startsWith("blob:") == true &&
+ result.optString("svgSource").startsWith("blob:")
+ ) break
+ Thread.sleep(100)
+ }
+
+ assertTrue(
+ "Raster image did not receive a dithered blob: $result",
+ result?.optString("source")?.startsWith("blob:") == true,
+ )
+ assertTrue(
+ "Raster image used the fallback filter: $result",
+ result?.optString("filter").orEmpty().isBlank(),
+ )
+ assertTrue(
+ "An SVG raster image did not receive error diffusion: $result",
+ result?.optString("svgSource")?.startsWith("blob:") == true,
+ )
+ assertTrue(
+ "The SVG container used the fallback filter: $result",
+ result?.optString("svgFilter").orEmpty().isBlank(),
+ )
+ val configuration = result?.optJSONObject("configuration")
+ assertEquals("ostromoukhov", configuration?.optString("algorithm"))
+ assertEquals(16, configuration?.optInt("levels"))
+ assertTrue(configuration?.optBoolean("serpentine") == true)
+
+ val pixels = evaluateJson(
+ scenario,
+ webView.get(),
+ "(function(){var image=document.querySelector('.alex-current-page img'),canvas=document.createElement('canvas');" +
+ "canvas.width=image.naturalWidth;canvas.height=image.naturalHeight;var context=canvas.getContext('2d');" +
+ "context.drawImage(image,0,0);var data=context.getImageData(0,0,canvas.width,canvas.height).data," +
+ "invalid=0,levels={};for(var i=0;i<data.length;i+=4){if(data[i]!==data[i+1]||data[i]!==data[i+2]||data[i]%17!==0)invalid++;levels[data[i]]=1;}" +
+ "var bounds=image.getBoundingClientRect();return JSON.stringify({invalid:invalid,levels:Object.keys(levels).length," +
+ "width:canvas.width,renderedWidth:Math.round(bounds.width*devicePixelRatio)," +
+ "imageRendering:getComputedStyle(image).imageRendering});})()",
+ )
+ assertEquals(
+ "Dithered pixels did not use the native grayscale palette: $pixels",
+ 0,
+ pixels?.optInt("invalid"),
+ )
+ assertEquals(16, pixels?.optInt("levels"))
+ assertEquals("pixelated", pixels?.optString("imageRendering"))
+ assertTrue(
+ "Dither resolution did not match the panel pixels: $pixels",
+ kotlin.math.abs(pixels!!.getInt("width") - pixels.getInt("renderedWidth")) <= 1,
+ )
+ }
+ }
+
@Test
fun clampsPrimitiveBridgeValuesAndDropsCallbacksAfterDestroy() {
val prepared = prepareFixedPublication("bridge")
},
)
activity.setContentView(reader)
- reader.load(prepared.reader, prepared.publication.rootDirectory)
+ reader.load(prepared.reader, prepared.publication)
}
assertTrue("Reader bridge did not become ready", ready.await(10, TimeUnit.SECONDS))
scenario.onActivity {
return Prepared(publication, EpubHtmlBuilder.write(publication, File(directory, "reader.html")))
}
+ private fun evaluateJson(
+ scenario: ActivityScenario<MainActivity>,
+ webView: ReaderWebView,
+ expression: String,
+ ): JSONObject? {
+ val completed = CountDownLatch(1)
+ val encoded = AtomicReference<String>()
+ scenario.onActivity {
+ webView.evaluateJavascript(expression) { value ->
+ encoded.set(value)
+ completed.countDown()
+ }
+ }
+ if (!completed.await(2, TimeUnit.SECONDS)) return null
+ val decoded = runCatching { JSONArray("[${encoded.get()}]").getString(0) }.getOrNull() ?: return null
+ return runCatching { JSONObject(decoded) }.getOrNull()
+ }
+
private fun listener(
ready: () -> Unit = {},
layoutReady: () -> Unit = {},
--- /dev/null
+/*
+ * 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);
--- /dev/null
+import { describe, expect, test } from "bun:test";
+
+const source = await Bun.file("app/src/main/assets/reader-dither.js").text();
+const windowStub = {
+ innerWidth: 1404,
+ innerHeight: 1872,
+ addEventListener() {},
+};
+const load = new Function("window", "self", `${source}\nreturn window.AlexDither;`);
+const dither = load(windowStub, windowStub) as {
+ ditherImageData(image: { width: number; height: number; data: Uint8ClampedArray }): void;
+ configuration: { algorithm: string; levels: number; serpentine: boolean; panel: string };
+};
+
+function grayscale(width: number, height: number, value: (x: number, y: number) => number) {
+ const data = new Uint8ClampedArray(width * height * 4);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const offset = (y * width + x) * 4;
+ const gray = value(x, y);
+ data[offset] = gray;
+ data[offset + 1] = gray;
+ data[offset + 2] = gray;
+ data[offset + 3] = 255;
+ }
+ }
+ return { width, height, data };
+}
+
+describe("Carta image dithering", () => {
+ test("uses Ostromoukhov serpentine diffusion and all 16 panel levels", () => {
+ expect(dither.configuration).toEqual({
+ algorithm: "ostromoukhov",
+ levels: 16,
+ serpentine: true,
+ panel: "Carta 1000",
+ });
+
+ const image = grayscale(16, 4, (x) => x * 17);
+ dither.ditherImageData(image);
+ expect(Array.from(image.data.filter((_, index) => index % 4 !== 3))).toEqual(
+ Array.from({ length: 16 * 4 * 3 }, (_, index) => Math.floor(index / 3) % 16 * 17),
+ );
+ });
+
+ test("produces deterministic monochrome output without changing alpha", () => {
+ const data = new Uint8ClampedArray(32 * 24 * 4);
+ let random = 0x12345678;
+ for (let index = 0; index < data.length; index += 4) {
+ random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
+ data[index] = random & 255;
+ data[index + 1] = random >>> 8 & 255;
+ data[index + 2] = random >>> 16 & 255;
+ data[index + 3] = index % 28 === 0 ? 0 : index % 20 === 0 ? 128 : 255;
+ }
+ const originalAlpha = Array.from(data.filter((_, index) => index % 4 === 3));
+ const first = { width: 32, height: 24, data: data.slice() };
+ const second = { width: 32, height: 24, data: data.slice() };
+
+ dither.ditherImageData(first);
+ dither.ditherImageData(second);
+
+ expect(first.data).toEqual(second.data);
+ expect(Array.from(first.data.filter((_, index) => index % 4 === 3))).toEqual(originalAlpha);
+ for (let index = 0; index < first.data.length; index += 4) {
+ if (first.data[index + 3] === 0) continue;
+ expect(first.data[index]).toBe(first.data[index + 1]);
+ expect(first.data[index]).toBe(first.data[index + 2]);
+ expect(first.data[index] % 17).toBe(0);
+ }
+ });
+
+ test("maps each panel interval through the published coefficients with a serpentine scan", () => {
+ const image = grayscale(12, 6, (x, y) => (x * 23 + y * 37 + x * y * 11) % 256);
+ dither.ditherImageData(image);
+ const output = Array.from(image.data.filter((_, index) => index % 4 === 0));
+ expect(output).toEqual([
+ 0,17,51,68,85,119,136,170,187,204,238,255,
+ 34,68,102,136,170,204,238,17,51,85,119,153,
+ 68,119,170,221,255,51,85,136,170,221,17,51,
+ 119,170,221,17,85,136,187,255,51,102,153,221,
+ 153,204,34,85,170,221,34,102,170,238,51,119,
+ 187,0,85,170,238,68,136,221,34,119,204,17,
+ ]);
+ });
+
+ test("uses Rec. 709 luma for color images", () => {
+ const image = grayscale(128, 96, () => 0);
+ for (let index = 0; index < image.data.length; index += 4) image.data[index] = 255;
+ dither.ditherImageData(image);
+ let sum = 0;
+ for (let index = 0; index < image.data.length; index += 4) sum += image.data[index];
+ expect(sum / (image.width * image.height)).toBeCloseTo(255 * 0.2126, 0);
+ });
+
+ test("preserves the mean tone of a uniform mid-gray patch", () => {
+ const image = grayscale(192, 128, () => 128);
+ dither.ditherImageData(image);
+ let sum = 0;
+ for (let index = 0; index < image.data.length; index += 4) sum += image.data[index];
+ expect(sum / (image.width * image.height)).toBeCloseTo(128, 0);
+ });
+});