Skip to content

Commit 34d6bd8

Browse files
committed
Merge fix/image-rendering: draw a chapter's image once, at the right size
2 parents e2ea100 + 1b959ca commit 34d6bd8

9 files changed

Lines changed: 385 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ Notable changes, newest first. Versions are `YYYY.MM.build` — the year and mon
44
plus a counter within that month. Each release's APK is on the
55
[Releases page](https://github.com/rephlex00/sn-reader/releases).
66

7+
## 2026.07.6
8+
9+
Pictures. A book that drew a small illustration at the head of each chapter was drawing it
10+
twice, and both copies too small.
11+
12+
### Fixed
13+
14+
* **Chapter illustrations appeared twice, at two different sizes.** Books converted for the
15+
Kindle routinely carry two copies of the same picture — one for each of Amazon's two formats
16+
— and hide the spare one. Reader was not reading the instruction to hide it, so it drew both.
17+
This also stops Reader reading the things those books hide on purpose, like print-edition page
18+
numbers and a second, redundant table of contents, as though they were part of the story.
19+
* **Every picture in a book was drawn at roughly half the size it was meant to be.** Book
20+
illustrations are sized for an ordinary screen, and Reader was taking those sizes literally on
21+
a much finer one — so a chapter ornament covered a fifth of the page instead of a third.
22+
Pictures are now drawn at their intended size, still centered, and still shrunk to fit when
23+
they are larger than the page. A full-page illustration looks exactly as it did; it is the
24+
small ones that were wrong.
25+
26+
Page previews are rebuilt once per book after this update, since where the pages fall has moved.
27+
728
## 2026.07.5
829

930
Mostly a repair release, from an audit of the whole app: a library that could forget your books, a

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ archive does not open yet. No PDF.
4848
**2. Install it over USB.**
4949

5050
```
51-
adb install -r sn-reader-2026.07.5.apk
51+
adb install -r sn-reader-2026.07.6.apk
5252
```
5353

5454
Debug mode needs to be on first. It lives in the Supernote's own Settings under security and

‎app/src/main/kotlin/dev/reader/ui/PreviewStrip.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,14 @@ private fun sha256Hex(text: String): String {
9898
* "strip-v3": expandSelfClosingTags stopped page anchors from eating the following footnote
9999
* marker's sup span, so those markers now measure 0.75x — line breaks and pagination shift in
100100
* every endnote-heavy book with print-edition page anchors.
101+
*
102+
* "strip-v4": the parser started honoring `display: none` (content the publisher hid no longer
103+
* paginates at all) and images are now sized in CSS pixels rather than device pixels (so every
104+
* image below the content box grew by the panel density). Both change how many lines a chapter
105+
* occupies. The density half IS visible to [configHash] — it is a new [RenderConfig] field — but
106+
* the display:none half is a pure renderer change, so the version bump is what covers it.
101107
*/
102-
private const val INDEX_VERSION = "strip-v3"
108+
private const val INDEX_VERSION = "strip-v4"
103109

104110
/** Flat text, no JSON dependency: a version line, a header line, then one line per entry. */
105111
fun StripIndex.serialize(): String = buildString {

‎app/src/main/kotlin/dev/reader/ui/ReaderPrefs.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ class ReaderPrefs(context: Context) {
2828

2929
private val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
3030

31+
/**
32+
* The panel's device-pixels-per-CSS-pixel, read once here rather than at each render:
33+
* it cannot change without the process being recreated. Not a stored preference — like
34+
* the viewport it is a property of the screen, not a choice — but unlike the viewport it
35+
* needs no argument, since it does not change with rotation. Feeds [RenderConfig.density],
36+
* which sizes a book's images; see its KDoc.
37+
*/
38+
private val density: Float = context.resources.displayMetrics.density
39+
3140
/** A bundled reader face — one of [BundledTypefaceProvider.FAMILIES] ("literata"/"bitter"/
3241
* "atkinson"). A legacy or unknown value resolves to the default face at render time (see
3342
* [BundledTypefaceProvider.fontResFor]); the store itself is a dumb string. */
@@ -160,6 +169,7 @@ class ReaderPrefs(context: Context) {
160169
// with the panel. See columnCountFor.
161170
columnCount = columns,
162171
columnGapPx = gap,
172+
density = density,
163173
)
164174
}
165175

‎engine/src/main/kotlin/dev/reader/engine/RenderConfig.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ data class RenderConfig(
7979
val columnCount: Int = 1,
8080
/** Whitespace between columns; ignored when [columnCount] is 1. See [COLUMN_GAP_PX]. */
8181
val columnGapPx: Int = COLUMN_GAP_PX,
82+
/**
83+
* The panel's display density — device pixels per CSS pixel. Everything else in this
84+
* class is already in raw device pixels, so this exists for the one input that is NOT:
85+
* an EPUB's image dimensions, which (like all CSS lengths) are authored in CSS pixels
86+
* on the assumption of a ~1x screen. Drawing such an image at its raw pixel count on a
87+
* 1.875x panel renders it at barely half the size the publisher drew it — the same
88+
* class of mistake the running foot made before 2026.07.2. See the image branch of
89+
* `SpannedChapterBuilder`.
90+
*
91+
* Defaults to 1.0 (raw pixels are CSS pixels), which is both the correct value for a
92+
* 1x display and the behavior every caller had before this field existed.
93+
*/
94+
val density: Float = 1f,
8295
) {
8396
/**
8497
* The width of one column of text — NOT the full width between the margins. With
@@ -97,6 +110,7 @@ data class RenderConfig(
97110
require(marginPx >= 0) { "marginPx must be non-negative, was $marginPx" }
98111
require(columnCount >= 1) { "columnCount must be at least 1, was $columnCount" }
99112
require(columnGapPx >= 0) { "columnGapPx must be non-negative, was $columnGapPx" }
113+
require(density > 0f) { "density must be positive, was $density" }
100114
require(contentWidthPx > 0) { "margins leave no content width: $this" }
101115
require(contentHeightPx > 0) { "margins leave no content height: $this" }
102116
}

‎formats/src/main/kotlin/dev/reader/formats/epub/XhtmlBlockParser.kt‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import dev.reader.engine.InlineStyle
66
import dev.reader.engine.StyleSpan
77
import dev.reader.engine.StyledText
88
import dev.reader.engine.TextAlign
9+
import dev.reader.engine.isSeparatorLine
910
import org.jsoup.Jsoup
1011
import org.jsoup.nodes.Document
1112
import org.jsoup.nodes.Element
@@ -22,7 +23,9 @@ import kotlin.math.abs
2223
* color → grayLevel, text-decoration → underline/strikethrough, letter-spacing, and the
2324
* block-level text-align/margins/text-indent/line-height. Box-layout properties (float,
2425
* position, border, background, width/height, padding, `display` beyond the block/inline
25-
* split already inferred) are ignored, never half-honored.
26+
* split already inferred) are ignored, never half-honored — with one exception:
27+
* `display: none` removes an element and its subtree, because that is a statement about
28+
* what the book *contains*, not about how it looks. See [isHidden].
2629
*
2730
* Font-size is resolved to a **ratio** against the document's own baseline, never an
2831
* absolute size: `em`/`rem`/`%`/keywords compose into [ComputedStyle.fontSizeRatio], and a
@@ -255,6 +258,7 @@ class XhtmlBlockParser {
255258
inferHeadings: Boolean,
256259
) {
257260
el.childNodes().forEach { node ->
261+
if (node is Element && isHidden(node, chain + ctxOf(node), css)) return@forEach
258262
when (node) {
259263
is TextNode -> builder.appendText(node.wholeText)
260264
is Element -> when (val tag = node.tagName().lowercase()) {
@@ -349,6 +353,7 @@ class XhtmlBlockParser {
349353
var ordinal = 0
350354
fun walk(container: Element, containerChain: List<ElementCtx>) {
351355
container.childNodes().forEach { node ->
356+
if (node is Element && isHidden(node, containerChain + ctxOf(node), css)) return@forEach
352357
when (node) {
353358
is TextNode -> {
354359
val builder = InlineBuilder()
@@ -381,6 +386,43 @@ class XhtmlBlockParser {
381386
return declarations["page-break-before"] == "always" || declarations["break-before"] == "page"
382387
}
383388

389+
/**
390+
* Whether [el] (the element ending [chain]) is removed from the rendered document by
391+
* `display: none`, resolved through the full cascade (so a descendant selector and an
392+
* inline `style` attribute both count). Its whole subtree goes with it: `display: none`
393+
* generates no box for the element *or* its descendants, so the walk simply never
394+
* enters it — which is also why this needs no separate inherit-down flag.
395+
*
396+
* This is the one box-layout property the parser honors, and it earns the exception by
397+
* being about *existence* rather than appearance: honoring `width`/`float`/`border`
398+
* would change how content looks, but ignoring `display: none` renders content the
399+
* publisher said is not part of this book at all. Dual-target exports make that routine
400+
* — an Amazon-converted book ships both the KF8 and the mobi7 rendering of a chapter
401+
* ornament in the markup and hides one in CSS, so ignoring the rule drew every chapter
402+
* ornament twice, at two different sizes (device-observed in Dungeon Crawler Carl).
403+
* The same books hide print-only page numbers, non-printing chapter titles and a
404+
* duplicate text table of contents, all of which were being read as body prose.
405+
*
406+
* **A hidden scene-break line is kept.** Those same exports write an ornamental break as
407+
* an image with a hidden `* * *` beside it as the text fallback, and their image is an
408+
* SVG — which [android.graphics.BitmapFactory] cannot decode, so it renders as nothing.
409+
* Honoring the rule literally therefore deleted the scene break outright: 313 of them
410+
* across four Dungeon Crawler Carl books, reopening exactly the bug 2026.07.2 closed
411+
* (blank space at a page boundary is indistinguishable from the page simply ending).
412+
* Keeping the fallback is the conservative direction — the reader renders its own mark
413+
* for it either way, so a book whose ornament image DOES decode shows the mark under the
414+
* ornament rather than losing the break. [isSeparatorLine] is a tight predicate
415+
* (punctuation only, no letters or digits), and an `<img>` has no text at all, so the
416+
* exception cannot rescue the duplicate images this rule exists to remove.
417+
*
418+
* Any other `display` value is ignored, exactly as before: the block/inline split is
419+
* already inferred from the tag, and half-honoring `display` would be worse than not.
420+
*/
421+
private fun isHidden(el: Element, chain: List<ElementCtx>, css: CssRules): Boolean {
422+
if (css.resolve(chain, baselinePx)["display"] != "none") return false
423+
return !isSeparatorLine(el.text())
424+
}
425+
384426
private fun emitImage(el: Element, chapterPath: String, out: MutableList<Block>) {
385427
val src = el.attr("src")
386428
if (src.isBlank()) return
@@ -454,6 +496,7 @@ class XhtmlBlockParser {
454496
chain: List<ElementCtx>,
455497
css: CssRules,
456498
) {
499+
if (node is Element && isHidden(node, chain + ctxOf(node), css)) return
457500
when (node) {
458501
is TextNode -> builder.appendText(node.wholeText)
459502

‎formats/src/main/kotlin/dev/reader/formats/render/SpannedChapterBuilder.kt‎

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -318,14 +318,15 @@ class SpannedChapterBuilder {
318318
* Never decodes a full-resolution image into memory: [BitmapFactory.Options.inJustDecodeBounds]
319319
* reads the header first, [sampleSizeFor] picks a power-of-two downsample factor, and only
320320
* then is the image decoded for real — the same protection the cover extractor uses (a 16 MB
321-
* JPEG decodes to hundreds of MB otherwise). The decoded bitmap is bounded to the content box
322-
* ([RenderConfig.contentWidthPx] x [contentHeightPx], aspect preserved, only ever shrunk) and
323-
* rendered gray via a saturation-0 colour filter on the drawable — the e-ink panel has no
324-
* colour, and a filter avoids allocating a second bitmap just to drop the colour.
321+
* JPEG decodes to hundreds of MB otherwise). The image is rendered gray via a saturation-0
322+
* colour filter on the drawable — the e-ink panel has no colour, and a filter avoids
323+
* allocating a second bitmap just to drop the colour.
325324
*/
326325
private fun appendImage(sb: SpannableStringBuilder, block: Block.Image, config: RenderConfig) {
327326
val bytes = block.bytes ?: return
328-
val drawable = decodeGrayscaleDrawable(bytes, config.contentWidthPx, config.contentHeightPx) ?: return
327+
val drawable = decodeGrayscaleDrawable(
328+
bytes, config.contentWidthPx, config.contentHeightPx, config.density,
329+
) ?: return
329330
val start = sb.length
330331
sb.append(IMAGE_PLACEHOLDER)
331332
sb.setSpan(ImageSpan(drawable, ImageSpan.ALIGN_BASELINE), start, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
@@ -337,15 +338,46 @@ class SpannedChapterBuilder {
337338
)
338339
}
339340

340-
private fun decodeGrayscaleDrawable(bytes: ByteArray, maxWidthPx: Int, maxHeightPx: Int): BitmapDrawable? {
341+
/**
342+
* The drawable for [bytes], sized for display and no larger in memory than that.
343+
*
344+
* **Display size** is the source's dimensions read as CSS pixels — what they are, an EPUB
345+
* being CSS all the way down — so they scale by [density] before being fitted within the
346+
* content box ([maxWidthPx] x [maxHeightPx], aspect preserved). Reading them as device
347+
* pixels instead, as this did before, drew every image at 1/density of its intended size:
348+
* a 264px chapter ornament covered 19% of a 1404px Nomad panel rather than the ~35% the
349+
* publisher drew. The content box is still the hard ceiling, so a full-page illustration
350+
* is unaffected — density only ever changes images that had room to spare.
351+
*
352+
* **Memory** is bounded independently, and deliberately does not follow the display size
353+
* upward: [sampleSizeFor] downsamples the decode toward the display size, the result is
354+
* trimmed to it if the power-of-two floor overshot (the same two-step the cover extractor
355+
* uses), and a source *smaller* than the display size is kept at its own resolution and
356+
* stretched by the drawable's bounds at draw time. Allocating an upscaled bitmap would
357+
* cost several times the memory per cached chapter to invent detail the source never had;
358+
* [BitmapDrawable] filters as it draws, which is the same picture for free.
359+
*/
360+
private fun decodeGrayscaleDrawable(
361+
bytes: ByteArray,
362+
maxWidthPx: Int,
363+
maxHeightPx: Int,
364+
density: Float,
365+
): BitmapDrawable? {
341366
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
342367
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
343368
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
344369
return null // not decodable as an image — BitmapFactory reports this via -1, not a throw
345370
}
346371

372+
val (displayWidth, displayHeight) = fitWithin(
373+
(bounds.outWidth * density).roundToInt().coerceAtLeast(1),
374+
(bounds.outHeight * density).roundToInt().coerceAtLeast(1),
375+
maxWidthPx,
376+
maxHeightPx,
377+
)
378+
347379
val options = BitmapFactory.Options().apply {
348-
inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, maxWidthPx, maxHeightPx)
380+
inSampleSize = sampleSizeFor(bounds.outWidth, bounds.outHeight, displayWidth, displayHeight)
349381
}
350382
val sampled = try {
351383
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
@@ -357,19 +389,23 @@ class SpannedChapterBuilder {
357389
} ?: return null
358390

359391
// inSampleSize floors to a power of two >= the request, so the sampled bitmap can still
360-
// overshoot the content box by nearly 2x per axis. Trim it to the fit dimensions and drop
361-
// the oversized sampled bitmap — the same two-step the cover extractor uses — so what stays
362-
// resident in the chapter cache is the displayed size, not up to ~4x its pixel area. On a
363-
// memory-constrained e-ink device that difference matters across cached chapters.
364-
val (targetWidth, targetHeight) = fitWithin(sampled.width, sampled.height, maxWidthPx, maxHeightPx)
365-
val display = if (sampled.width == targetWidth && sampled.height == targetHeight) {
392+
// overshoot the display size by nearly 2x per axis. Trim it down — the same two-step the
393+
// cover extractor uses — so what stays resident in the chapter cache is the displayed
394+
// size, not up to ~4x its pixel area. On a memory-constrained e-ink device that
395+
// difference matters across cached chapters. fitWithin only ever shrinks, so a source
396+
// already smaller than the display size passes through untouched and is upscaled by the
397+
// bounds below instead of by an allocation.
398+
val (keepWidth, keepHeight) = fitWithin(sampled.width, sampled.height, displayWidth, displayHeight)
399+
val bitmap = if (sampled.width == keepWidth && sampled.height == keepHeight) {
366400
sampled
367401
} else {
368-
Bitmap.createScaledBitmap(sampled, targetWidth, targetHeight, true)
402+
Bitmap.createScaledBitmap(sampled, keepWidth, keepHeight, true)
369403
.also { if (it !== sampled) sampled.recycle() }
370404
}
371-
return BitmapDrawable(Resources.getSystem(), display).apply {
372-
setBounds(0, 0, display.width, display.height)
405+
return BitmapDrawable(Resources.getSystem(), bitmap).apply {
406+
// The bounds, NOT the bitmap's own size, are what the ImageSpan lays out and draws
407+
// to — so this is where an image smaller than its display size gets its upscale.
408+
setBounds(0, 0, displayWidth, displayHeight)
373409
// Saturation 0 renders the image gray; the panel is grayscale.
374410
colorFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
375411
}

0 commit comments

Comments
 (0)