]> git.otsuka.systems Git - alexandria/commitdiff
improve dithering algo master
authorCameron Otsuka <cameron@otsuka.haus>
Wed, 12 Aug 2026 00:17:01 +0000 (17:17 -0700)
committerCameron Otsuka <cameron@otsuka.haus>
Wed, 12 Aug 2026 00:17:01 +0000 (17:17 -0700)
14 files changed:
README.md
app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt
app/src/main/assets/reader-dither.js [new file with mode: 0644]
app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt
app/src/main/java/com/alexandria/reader/EpubParser.kt
app/src/main/java/com/alexandria/reader/Models.kt
app/src/main/java/com/alexandria/reader/ReaderScreen.kt
app/src/main/java/com/alexandria/reader/ReaderScripts.kt
app/src/main/java/com/alexandria/reader/ReaderWebView.kt
app/src/test/java/com/alexandria/reader/EpubParserTest.kt
scripts/verify-fixed-layout.ps1
tests/fixtures/fixed-layout-src/OEBPS/page3.xhtml
tests/fixtures/fixed-layout.epub
tests/javascript/reader-dither.test.ts [new file with mode: 0644]

index 4bce1a08c855650f8e40e76095c3a53a6d57ec70..bc20c8248903924832b709520f65bb7fa884510e 100644 (file)
--- 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
 - 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
 
 - 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
 
 ```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
 With an Android virtual device running, execute the WebView lifecycle and main-thread I/O tests with:
 
 ```powershell
index 52a465df295b60e7a46cc4688387269fdb759750..13a7e7d0ded30ceb1bb3f0d0bdedb7129dbae5c4 100644 (file)
@@ -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 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
 import org.json.JSONObject
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
@@ -48,7 +49,7 @@ class ReaderWebViewTest {
                     },
                 )
                 activity.setContentView(reader)
                     },
                 )
                 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))
             }
 
             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<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")
     @Test
     fun clampsPrimitiveBridgeValuesAndDropsCallbacksAfterDestroy() {
         val prepared = prepareFixedPublication("bridge")
@@ -84,7 +185,7 @@ class ReaderWebViewTest {
                     },
                 )
                 activity.setContentView(reader)
                     },
                 )
                 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 {
             }
             assertTrue("Reader bridge did not become ready", ready.await(10, TimeUnit.SECONDS))
             scenario.onActivity {
@@ -132,6 +233,24 @@ class ReaderWebViewTest {
         return Prepared(publication, EpubHtmlBuilder.write(publication, File(directory, "reader.html")))
     }
 
         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 = {},
     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 (file)
index 0000000..7868b19
--- /dev/null
@@ -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);
index 547112658c8ab5ae360b89d8da0af3157958fdf7..f0f018f13674e8f758c0dcc53ea77d61380d5ebd 100644 (file)
@@ -12,7 +12,8 @@ import kotlin.math.roundToInt
 
 /** Builds one secure, paginated HTML document from the complete EPUB spine. */
 internal object EpubHtmlBuilder {
 
 /** 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,
 
     private data class ChapterDocument(
         val index: Int,
@@ -304,7 +305,7 @@ internal object EpubHtmlBuilder {
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
-<meta http-equiv="Content-Security-Policy" content="default-src 'self' file: data: blob:; img-src file: data: blob:; media-src file: data: blob:; font-src file: data:; style-src 'unsafe-inline' file:; script-src 'unsafe-inline'; connect-src 'none'; frame-src 'none'; object-src 'none'">
+<meta http-equiv="Content-Security-Policy" content="default-src 'self' file: data: blob: $RESOURCE_ORIGIN; img-src file: data: blob: $RESOURCE_ORIGIN; media-src file: data: blob: $RESOURCE_ORIGIN; font-src file: data: $RESOURCE_ORIGIN; style-src 'unsafe-inline' file: $RESOURCE_ORIGIN; script-src 'unsafe-inline' file: blob:; worker-src blob:; connect-src 'none'; frame-src 'none'; object-src 'none'">
 <title>${escapeHtml(publication.book.title)}</title>
 <style id="alex-publisher-styles">
 $publisherCss
 <title>${escapeHtml(publication.book.title)}</title>
 <style id="alex-publisher-styles">
 $publisherCss
@@ -340,6 +341,9 @@ body.alex-fixed .alex-chapter.alex-current-page { display:block !important; }
 body.alex-continuous .alex-chapter { display:block; break-before:auto !important; margin-bottom:3em; transform:none !important; }
 .alex-chapter-start { display:block; position:absolute; left:0; top:0; width:0; height:0; overflow:hidden; }
 body:not(.alex-fixed) img, body:not(.alex-fixed) svg, body:not(.alex-fixed) video, body:not(.alex-fixed) canvas { max-width:100% !important; height:auto; object-fit:contain; break-inside:avoid; }
 body.alex-continuous .alex-chapter { display:block; break-before:auto !important; margin-bottom:3em; transform:none !important; }
 .alex-chapter-start { display:block; position:absolute; left:0; top:0; width:0; height:0; overflow:hidden; }
 body:not(.alex-fixed) img, body:not(.alex-fixed) svg, body:not(.alex-fixed) video, body:not(.alex-fixed) canvas { max-width:100% !important; height:auto; object-fit:contain; break-inside:avoid; }
+.alex-dither-pending { filter:grayscale(1) !important; }
+.alex-dithered { filter:none !important; image-rendering:pixelated !important; }
+.alex-dithered-svg, .alex-dither-fallback { image-rendering:pixelated !important; }
 body.alex-fixed .alex-chapter > svg:only-child { display:block; width:100%; height:100%; max-width:none !important; max-height:none !important; }
 body.alex-paged .x-ebookmaker-cover { height:calc(100vh - (2 * var(--alex-v))) !important; max-height:calc(100vh - (2 * var(--alex-v))) !important; overflow:hidden; }
 svg[height="100%"], .x-ebookmaker-cover svg { display:block; height:calc(100vh - (2 * var(--alex-v))) !important; max-height:calc(100vh - (2 * var(--alex-v))) !important; }
 body.alex-fixed .alex-chapter > svg:only-child { display:block; width:100%; height:100%; max-width:none !important; max-height:none !important; }
 body.alex-paged .x-ebookmaker-cover { height:calc(100vh - (2 * var(--alex-v))) !important; max-height:calc(100vh - (2 * var(--alex-v))) !important; overflow:hidden; }
 svg[height="100%"], .x-ebookmaker-cover svg { display:block; height:calc(100vh - (2 * var(--alex-v))) !important; max-height:calc(100vh - (2 * var(--alex-v))) !important; }
@@ -361,9 +365,10 @@ mark.alex-annotation { color:inherit !important; background:#ddd !important; bor
 <body class="${if (publication.fixedLayout) "alex-fixed" else "alex-paged"} alex-hyphenate">
 <svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs><filter id="alex-image-dither" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"></filter></defs></svg>
 $content
 <body class="${if (publication.fixedLayout) "alex-fixed" else "alex-paged"} alex-hyphenate">
 <svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs><filter id="alex-image-dither" x="-10%" y="-10%" width="120%" height="120%" color-interpolation-filters="sRGB"></filter></defs></svg>
 $content
+<script src="file:///android_asset/reader-dither.js"></script>
 <script>
 window.ALEX_REFERENCES = $references;
 <script>
 window.ALEX_REFERENCES = $references;
-$IMAGE_DITHER_SCRIPT
+$READER_SHARED_SCRIPT
 ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT}
 </script>
 </body>
 ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT}
 </script>
 </body>
@@ -465,10 +470,12 @@ ${if (publication.fixedLayout) FIXED_READER_SCRIPT else REFLOWABLE_READER_SCRIPT
     private fun resourceUrl(base: String, value: String, root: File): String? {
         if (value.isBlank() || isExternal(value) || value.startsWith('#')) return null
         val path = EpubParser.normalizePath(base, value.substringBefore('#').substringBefore('?'))
     private fun resourceUrl(base: String, value: String, root: File): String? {
         if (value.isBlank() || isExternal(value) || value.startsWith('#')) return null
         val path = EpubParser.normalizePath(base, value.substringBefore('#').substringBefore('?'))
-        val fileUrl = runCatching { EpubParser.safeFile(root, path) }.getOrNull()
-            ?.takeIf(File::isFile)?.toURI()?.toASCIIString()?.replace("'", "%27") ?: return null
+        val exists = runCatching { EpubParser.safeFile(root, path) }.getOrNull()?.isFile == true
+        if (!exists) return null
+        val namespace = encodeUriComponent(root.parentFile?.name ?: root.name)
+        val resourceUrl = "$RESOURCE_ORIGIN/content/$namespace/${path.split('/').joinToString("/") { encodeUriComponent(it) }}"
         val fragment = value.substringAfter('#', "")
         val fragment = value.substringAfter('#', "")
-        return if (fragment.isBlank()) fileUrl else "$fileUrl#${encodeUriComponent(EpubParser.decodeUriComponent(fragment))}"
+        return if (fragment.isBlank()) resourceUrl else "$resourceUrl#${encodeUriComponent(EpubParser.decodeUriComponent(fragment))}"
     }
 
     /** Re-targets every selector to the spine section that supplied the CSS. */
     }
 
     /** Re-targets every selector to the spine section that supplied the CSS. */
index cb8916d7c0a7eed14315b4abda65fff738147d28..07ab7afb90bcef190f7ec89e73da6e663f189465 100644 (file)
@@ -75,12 +75,15 @@ internal object EpubParser {
                 WorkCancellation.check()
                 val id = element.attr("id")
                 val href = element.attr("href")
                 WorkCancellation.check()
                 val id = element.attr("id")
                 val href = element.attr("href")
-                require(id.isNotBlank() && href.isNotBlank()) { "Every EPUB manifest item must have an ID and an href." }
+                val mediaType = element.attr("media-type").lowercase(Locale.ROOT)
+                require(id.isNotBlank() && href.isNotBlank() && mediaType.isNotBlank()) {
+                    "Every EPUB manifest item must have an ID, an href, and a media type."
+                }
                 val previous = manifest.put(
                     id,
                     ManifestItem(
                         href = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')),
                 val previous = manifest.put(
                     id,
                     ManifestItem(
                         href = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')),
-                        mediaType = element.attr("media-type").lowercase(Locale.ROOT),
+                        mediaType = mediaType,
                         properties = element.attr("properties").lowercase(Locale.ROOT)
                             .split(WHITESPACE).filter(String::isNotBlank).toSet(),
                     ),
                         properties = element.attr("properties").lowercase(Locale.ROOT)
                             .split(WHITESPACE).filter(String::isNotBlank).toSet(),
                     ),
@@ -171,6 +174,7 @@ internal object EpubParser {
             fixedLayout = fixedLayout,
             defaultPageWidth = pageSize.first,
             defaultPageHeight = pageSize.second,
             fixedLayout = fixedLayout,
             defaultPageWidth = pageSize.first,
             defaultPageHeight = pageSize.second,
+            resourceMediaTypes = manifest.values.associate { it.href to it.mediaType },
         )
     }
 
         )
     }
 
index f907d9327c01413b932dc1ddd5eb7c20caf33225..0b8ecfba3a73e009c6c3e048b6321f2e74157b79 100644 (file)
@@ -82,6 +82,7 @@ internal data class EpubPublication(
     val fixedLayout: Boolean,
     val defaultPageWidth: Int,
     val defaultPageHeight: Int,
     val fixedLayout: Boolean,
     val defaultPageWidth: Int,
     val defaultPageHeight: Int,
+    val resourceMediaTypes: Map<String, String> = emptyMap(),
 )
 
 internal data class ReaderLocation(
 )
 
 internal data class ReaderLocation(
index c93c44f3bd29f3eb6edd7e6d16f2ef93bea4afaa..00bce7b48ab78f3ddde1378fb7aa3a2104b0e0ac 100644 (file)
@@ -78,7 +78,7 @@ internal class ReaderScreen(
         isFocusableInTouchMode = true
         buildReader()
         webView.listener = this
         isFocusableInTouchMode = true
         buildReader()
         webView.listener = this
-        webView.load(readerFile, publication.rootDirectory)
+        webView.load(readerFile, publication)
         repository.markOpened(publication.book.id)
         applyBrightness()
     }
         repository.markOpened(publication.book.id)
         applyBrightness()
     }
index 98ca388d5d62b42643b001e20ecb236c7cbee111..030fe75799db4aa59aa8e4c05a6dee0da90d6e2e 100644 (file)
@@ -1,29 +1,13 @@
 package com.alexandria.reader
 
 package com.alexandria.reader
 
-internal const val IMAGE_DITHER_SCRIPT = """
+internal const val READER_SHARED_SCRIPT = """
 function applyPublisherStyles(enabled) {
   document.getElementById('alex-publisher-styles').disabled=!enabled;
   Array.prototype.forEach.call(document.querySelectorAll('[data-alex-publisher-style]'),function(element){
     if(enabled)element.setAttribute('style',element.getAttribute('data-alex-publisher-style'));else element.removeAttribute('style');
   });
 }
 function applyPublisherStyles(enabled) {
   document.getElementById('alex-publisher-styles').disabled=!enabled;
   Array.prototype.forEach.call(document.querySelectorAll('[data-alex-publisher-style]'),function(element){
     if(enabled)element.setAttribute('style',element.getAttribute('data-alex-publisher-style'));else element.removeAttribute('style');
   });
 }
-function applyImageDithering(root) {
-  var filter=document.getElementById('alex-image-dither');
-  if(!filter)return;
-  if(!filter.firstChild){
-    function node(name,attributes){var value=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){value.setAttribute(key,String(attributes[key]));});return value;}
-    filter.appendChild(node('feColorMatrix',{type:'matrix',result:'alex-gray',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(node('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'}));
-    filter.appendChild(node('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:'.065 0 0 0 -.0325  .065 0 0 0 -.0325  .065 0 0 0 -.0325  0 0 0 1 0'}));
-    filter.appendChild(node('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'}));
-    var levels=[];for(var level=0;level<16;level++)levels.push((level/15).toFixed(4));
-    var quantized=node('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'});
-    ['R','G','B'].forEach(function(channel){quantized.appendChild(node('feFunc'+channel,{type:'discrete',tableValues:levels.join(' ')}));});
-    quantized.appendChild(node('feFuncA',{type:'table',tableValues:'0 1'}));filter.appendChild(quantized);
-    filter.appendChild(node('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'}));
-  }
-  Array.prototype.forEach.call(root.querySelectorAll('img,canvas,video,.alex-chapter svg'),function(image){image.style.filter='url(#alex-image-dither)';});
-}
+function applyImageDithering(root) { if(window.AlexDither)window.AlexDither.apply(root); }
 """
 
 internal const val REFLOWABLE_READER_SCRIPT = """
 """
 
 internal const val REFLOWABLE_READER_SCRIPT = """
@@ -54,7 +38,7 @@ internal const val REFLOWABLE_READER_SCRIPT = """
   function showPaged(page, report) {
     state.page=clamp(Math.round(Number(page)||0),0,state.total-1);
     var chapter=chapterAt(state.page),local=state.page-(state.chapterStarts[chapter]||0),element=setVisibleChapter(chapter);
   function showPaged(page, report) {
     state.page=clamp(Math.round(Number(page)||0),0,state.total-1);
     var chapter=chapterAt(state.page),local=state.page-(state.chapterStarts[chapter]||0),element=setVisibleChapter(chapter);
-    element.style.transform='translateX('+(-local*Math.max(1,window.innerWidth))+'px)';
+    element.style.transform='translateX('+(-local*Math.max(1,window.innerWidth))+'px)';applyImageDithering(element);
     if(report!==false)notifyPage();
   }
   function scrollToPage(page, report) {
     if(report!==false)notifyPage();
   }
   function scrollToPage(page, report) {
index 1300fa8c0dd5b0f8a319db8eda6272fe77beff18..749c4e53be8ac06d699998577aad886dbe888b9c 100644 (file)
@@ -12,6 +12,7 @@ import android.view.MenuItem
 import android.view.MotionEvent
 import android.webkit.ConsoleMessage
 import android.webkit.JavascriptInterface
 import android.view.MotionEvent
 import android.webkit.ConsoleMessage
 import android.webkit.JavascriptInterface
+import android.webkit.MimeTypeMap
 import android.webkit.ValueCallback
 import android.webkit.WebChromeClient
 import android.webkit.WebResourceError
 import android.webkit.ValueCallback
 import android.webkit.WebChromeClient
 import android.webkit.WebResourceError
@@ -24,6 +25,7 @@ import org.json.JSONArray
 import org.json.JSONObject
 import java.io.ByteArrayInputStream
 import java.io.File
 import org.json.JSONObject
 import java.io.ByteArrayInputStream
 import java.io.File
+import java.net.URLConnection
 import kotlin.math.abs
 
 @SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
 import kotlin.math.abs
 
 @SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
@@ -53,6 +55,7 @@ internal class ReaderWebView(context: Context) : WebView(context) {
     private var pageLoaded = false
     private var loadedReader: File? = null
     private var resourceRoot: File? = null
     private var pageLoaded = false
     private var loadedReader: File? = null
     private var resourceRoot: File? = null
+    private var resourceMediaTypes: Map<String, String> = emptyMap()
     private var suppressTapUntil = 0L
 
     private val gestures = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
     private var suppressTapUntil = 0L
 
     private val gestures = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
@@ -122,6 +125,9 @@ internal class ReaderWebView(context: Context) : WebView(context) {
 
             override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
                 val uri = request?.url ?: return emptyResponse()
 
             override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
                 val uri = request?.url ?: return emptyResponse()
+                if (uri.scheme.equals("https", true) && uri.host.equals(RESOURCE_HOST, true)) {
+                    return publicationResource(uri.path) ?: emptyResponse()
+                }
                 val allowed = when (uri.scheme?.lowercase()) {
                     "data", "blob" -> true
                     "file" -> isAllowedFile(uri.path)
                 val allowed = when (uri.scheme?.lowercase()) {
                     "data", "blob" -> true
                     "file" -> isAllowedFile(uri.path)
@@ -138,20 +144,45 @@ internal class ReaderWebView(context: Context) : WebView(context) {
         }
     }
 
         }
     }
 
-    fun load(file: File, resources: File) {
+    fun load(file: File, publication: EpubPublication) {
         pageLoaded = false
         loadedReader = file.canonicalFile
         pageLoaded = false
         loadedReader = file.canonicalFile
-        resourceRoot = resources.canonicalFile
+        resourceRoot = publication.rootDirectory.canonicalFile
+        resourceMediaTypes = publication.resourceMediaTypes
         loadUrl(requireNotNull(loadedReader).toURI().toASCIIString())
     }
 
     private fun isAllowedFile(path: String?): Boolean {
         if (path == null) return false
         loadUrl(requireNotNull(loadedReader).toURI().toASCIIString())
     }
 
     private fun isAllowedFile(path: String?): Boolean {
         if (path == null) return false
-        if (path.startsWith("/android_res/")) return true
+        if (path.startsWith("/android_res/") || path == "/android_asset/reader-dither.js") return true
         val candidate = runCatching { File(path).canonicalFile }.getOrNull() ?: return false
         val candidate = runCatching { File(path).canonicalFile }.getOrNull() ?: return false
-        if (candidate == loadedReader) return true
-        val root = resourceRoot ?: return false
-        return candidate.path == root.path || candidate.path.startsWith(root.path + File.separator)
+        return candidate == loadedReader
+    }
+
+    /** Serves private EPUB files from an isolated origin so canvas reads remain CORS-safe. */
+    private fun publicationResource(path: String?): WebResourceResponse? {
+        val namespacedPath = path?.removePrefix(CONTENT_PATH) ?: return null
+        if (namespacedPath == path || namespacedPath.isBlank()) return null
+        val root = resourceRoot ?: return null
+        val parts = namespacedPath.split('/', limit = 2)
+        if (parts.size != 2 || parts[0] != (root.parentFile?.name ?: root.name)) return null
+        val file = runCatching { EpubParser.safeFile(root, parts[1]) }.getOrNull()?.takeIf(File::isFile) ?: return null
+        val relativePath = parts[1]
+        val extension = file.extension.lowercase()
+        val mimeType = resourceMediaTypes[relativePath]
+            ?: MIME_TYPES[extension]
+            ?: MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
+            ?: runCatching { file.inputStream().buffered().use(URLConnection::guessContentTypeFromStream) }.getOrNull()
+            ?: "application/octet-stream"
+        val encoding = if (mimeType.startsWith("text/") || mimeType in UTF8_MIME_TYPES) "utf-8" else null
+        val headers = mapOf(
+            "Access-Control-Allow-Origin" to "*",
+            "Cache-Control" to "private, max-age=31536000, immutable",
+            "Content-Length" to file.length().toString(),
+        )
+        return runCatching {
+            WebResourceResponse(mimeType, encoding, 200, "OK", headers, file.inputStream())
+        }.getOrNull()
     }
 
     private fun emptyResponse(): WebResourceResponse =
     }
 
     private fun emptyResponse(): WebResourceResponse =
@@ -289,6 +320,7 @@ internal class ReaderWebView(context: Context) : WebView(context) {
         pageLoaded = false
         loadedReader = null
         resourceRoot = null
         pageLoaded = false
         loadedReader = null
         resourceRoot = null
+        resourceMediaTypes = emptyMap()
         mainHandler.removeCallbacksAndMessages(null)
         removeJavascriptInterface("Android")
         stopLoading()
         mainHandler.removeCallbacksAndMessages(null)
         removeJavascriptInterface("Android")
         stopLoading()
@@ -311,6 +343,18 @@ internal class ReaderWebView(context: Context) : WebView(context) {
         private const val ACTION_NOTE = 0xA111
         private const val ACTION_DEFINE = 0xA112
         private const val ACTION_SEARCH = 0xA113
         private const val ACTION_NOTE = 0xA111
         private const val ACTION_DEFINE = 0xA112
         private const val ACTION_SEARCH = 0xA113
+        private const val RESOURCE_HOST = "alexandria.invalid"
+        private const val CONTENT_PATH = "/content/"
+        private val UTF8_MIME_TYPES = setOf("application/xhtml+xml", "image/svg+xml")
+        private val MIME_TYPES = mapOf(
+            "avif" to "image/avif",
+            "otf" to "font/otf",
+            "svg" to "image/svg+xml",
+            "ttf" to "font/ttf",
+            "woff" to "font/woff",
+            "woff2" to "font/woff2",
+            "xhtml" to "application/xhtml+xml",
+        )
         private val LINK_HIT_TYPES = setOf(
             HitTestResult.SRC_ANCHOR_TYPE,
             HitTestResult.SRC_IMAGE_ANCHOR_TYPE,
         private val LINK_HIT_TYPES = setOf(
             HitTestResult.SRC_ANCHOR_TYPE,
             HitTestResult.SRC_IMAGE_ANCHOR_TYPE,
index f5fca0508b6c3182750e37e563dc483cc7b183a5..3b47589bb8ba4e2b03cd072d3546458fbbae4ad6 100644 (file)
@@ -44,6 +44,7 @@ class EpubParserTest {
         assertEquals(4, publication.spine.size)
         assertEquals(1200, publication.defaultPageWidth)
         assertEquals(1600, publication.defaultPageHeight)
         assertEquals(4, publication.spine.size)
         assertEquals(1200, publication.defaultPageWidth)
         assertEquals(1600, publication.defaultPageHeight)
+        assertEquals("image/png", publication.resourceMediaTypes["OEBPS/gradient.png"])
     }
 
     @Test
     }
 
     @Test
@@ -119,7 +120,8 @@ class EpubParserTest {
         val html = EpubHtmlBuilder.write(publication, temporary.newFile("reader.html")).readText()
 
         assertTrue(html.contains(".pick:is(.one,.two)"))
         val html = EpubHtmlBuilder.write(publication, temporary.newFile("reader.html")).readText()
 
         assertTrue(html.contains(".pick:is(.one,.two)"))
-        assertTrue(html.contains("sprite.svg#shape"))
+        assertTrue(Regex("https://alexandria\\.invalid/content/[^/]+/Images/sprite\\.svg#shape").containsMatchIn(html))
+        assertFalse(html.contains(root.absolutePath.replace('\\', '/')))
         assertTrue(html.contains("\"Text/chapter.xhtml#chapter-body\":\"alex-chapter-0\""))
         assertTrue(html.contains("lang=\"fr\""))
         val target = requireNotNull(Jsoup.parse(html).selectFirst("p"))
         assertTrue(html.contains("\"Text/chapter.xhtml#chapter-body\":\"alex-chapter-0\""))
         assertTrue(html.contains("lang=\"fr\""))
         val target = requireNotNull(Jsoup.parse(html).selectFirst("p"))
index 9fd12d71b59e7946b6f38e18c820b8a543e2ddc3..abbfc25b9009ee002abfacc4012c81bfed95d2a6 100644 (file)
@@ -89,7 +89,7 @@ try {
     Wait-Reader "Page 3 of 4"
     Capture "fixed-automatic-dithering"
 
     Wait-Reader "Page 3 of 4"
     Capture "fixed-automatic-dithering"
 
-    $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader|E/AlexandriaRenderer"
+    $fatal = & $adb -s $serial logcat -d -v brief | Select-String "FATAL EXCEPTION|Process: com.alexandria.reader|E/AlexandriaRenderer|Image dithering used its fallback"
     if ($fatal) { throw "Alexandria failed during fixed-layout verification.`n$($fatal -join "`n")" }
     Write-Host "Verified fixed-layout rendering, automatic image dithering, and position restoration. Screenshots are in $OutputDirectory"
 }
     if ($fatal) { throw "Alexandria failed during fixed-layout verification.`n$($fatal -join "`n")" }
     Write-Host "Verified fixed-layout rendering, automatic image dithering, and position restoration. Screenshots are in $OutputDirectory"
 }
index c51b047bc16df1d227202e91b7b7be5b4451ca04..6f8018390ac73ef441220d30eee8a54728985ee5 100644 (file)
@@ -1,2 +1,2 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <?xml version="1.0" encoding="UTF-8"?>
-<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Dithered image</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="edge-label north">PAGE TOP</span><span class="edge-label south">PAGE BOTTOM</span><span class="edge-label west">PAGE LEFT</span><span class="edge-label east">PAGE RIGHT</span><main class="trim"><h1>DITHERED IMAGE</h1><p>Images receive automatic 16-level grayscale spatial dithering.</p><p>Dithering has no reader control and remains consistent for every book.</p><img class="graphic" src="gradient.png" alt="Grayscale gradient"/><div class="footer">3</div></main></body></html>
+<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Dithered image</title><meta name="viewport" content="width=1200,height=1600"/><link rel="stylesheet" href="fixed.css"/></head><body><span class="edge-label north">PAGE TOP</span><span class="edge-label south">PAGE BOTTOM</span><span class="edge-label west">PAGE LEFT</span><span class="edge-label east">PAGE RIGHT</span><main class="trim"><h1>DITHERED IMAGE</h1><p>Images use automatic 16-level Ostromoukhov variable error diffusion.</p><p>The algorithm has no reader control and remains consistent for every book.</p><img class="graphic" src="gradient.png" alt="Grayscale gradient"/><div class="footer">3</div></main></body></html>
index e0d5ab3c1535fbf182e5b076b40c372d55a0a31f..91be360dd619832282d36f9768d722218438238f 100644 (file)
Binary files a/tests/fixtures/fixed-layout.epub and b/tests/fixtures/fixed-layout.epub differ
diff --git a/tests/javascript/reader-dither.test.ts b/tests/javascript/reader-dither.test.ts
new file mode 100644 (file)
index 0000000..62f7bcf
--- /dev/null
@@ -0,0 +1,103 @@
+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);
+  });
+});