From: Cameron Otsuka Date: Wed, 12 Aug 2026 00:17:01 +0000 (-0700) Subject: improve dithering algo X-Git-Url: https://git.otsuka.systems/?a=commitdiff_plain;h=refs%2Fheads%2Fmaster;p=alexandria improve dithering algo --- diff --git a/README.md b/README.md index 4bce1a0..bc20c82 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The app requests no network or shared-storage permission. It imports each select - Publisher or Atkinson Hyperlegible Next typeface, plus text size, line height, margin, alignment, publisher-style, and hyphenation controls - One high-contrast e-ink appearance with colors aligned to the display's 16 grayscale levels, plus screen-brightness control - Automatic fit-to-page rendering for pre-paginated EPUBs -- Automatic 16-level grayscale spatial dithering for images +- Automatic 16-level Ostromoukhov variable error-diffusion dithering for images, tuned for Carta 1000 panels - Portrait and landscape layouts - Searchable and sortable library with covers and reading states @@ -41,8 +41,11 @@ Run parser, renderer, repository, persistence, and model tests plus Android lint ```powershell .\gradlew.bat :app:testDebugUnitTest :app:lintDebug +bun test .\tests\javascript\reader-dither.test.ts ``` +Image processing uses the panel's 16 native grayscale levels and a serpentine scan. Each interval between adjacent panel grays is normalized through Ostromoukhov's published variable coefficients. Raster work runs in a browser worker and is limited to the Carta 1000 panel resolution. Raster images inside SVG are processed without flattening the surrounding vector document. Animated media and purely vector SVG retain a 16-level grayscale filter because frame-by-frame or destructive rasterization would block reading or remove document semantics. The implementation was informed by [epdoptimize](https://github.com/paperlesspaper/epdoptimize), [Ostromoukhov's paper](https://perso.liris.cnrs.fr/victor.ostromoukhov/publications/pdf/SIGGRAPH01_varcoeffED.pdf), and the [ED103TC2 specifications](https://www.eink.com/product/detail/ED103TC2). + With an Android virtual device running, execute the WebView lifecycle and main-thread I/O tests with: ```powershell diff --git a/app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt b/app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt index 52a465d..13a7e7d 100644 --- a/app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt +++ b/app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt @@ -5,6 +5,7 @@ import androidx.test.core.app.ActivityScenario 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 @@ -48,7 +49,7 @@ class ReaderWebViewTest { }, ) 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)) @@ -60,6 +61,106 @@ class ReaderWebViewTest { } } + @Test + fun appliesOstromoukhovDitheringToRasterImages() { + val prepared = prepareFixedPublication("dither") + val ready = CountDownLatch(1) + val layoutReady = CountDownLatch(1) + val webView = AtomicReference() + + 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, + webView: ReaderWebView, + expression: String, + ): JSONObject? { + val completed = CountDownLatch(1) + val encoded = AtomicReference() + 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 = {}, diff --git a/app/src/main/assets/reader-dither.js b/app/src/main/assets/reader-dither.js new file mode 100644 index 0000000..7868b19 --- /dev/null +++ b/app/src/main/assets/reader-dither.js @@ -0,0 +1,507 @@ +/* + * 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); diff --git a/app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt b/app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt index 5471126..f0f018f 100644 --- a/app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt +++ b/app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt @@ -12,7 +12,8 @@ import kotlin.math.roundToInt /** Builds one secure, paginated HTML document from the complete EPUB spine. */ internal object EpubHtmlBuilder { - internal const val FORMAT_VERSION = "2" + internal const val FORMAT_VERSION = "3" + internal const val RESOURCE_ORIGIN = "https://alexandria.invalid" private data class ChapterDocument( val index: Int, @@ -304,7 +305,7 @@ internal object EpubHtmlBuilder { - + ${escapeHtml(publication.book.title)}