@@ -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