Skip to content

Support decoding at a reduced resolution, for cheap thumbnails - #41

Merged
philips merged 1 commit into
mainfrom
scaled-thumbnail-decode
Jul 31, 2026
Merged

Support decoding at a reduced resolution, for cheap thumbnails#41
philips merged 1 commit into
mainfrom
scaled-thumbnail-decode

Conversation

@philips-clanker

Copy link
Copy Markdown
Collaborator

Summary

Closes #40. toImage() always rasterizes every layer at full pageWidth×pageHeight, even for a small thumbnail — the motivating case (a ~140px sidebar preview in the Obsidian plugin) pays the same decode/memory cost as actually viewing the page, contributing to memory pressure on memory-constrained iOS devices (see the issue's linked trail).

This implements the "real fix" the issue flags rather than the lower-risk decode-then-resize option: only decoding directly at the reduced resolution avoids ever allocating the full-resolution buffer. Resizing afterward still hits that peak allocation, which is what actually matters for the motivating crash — confirmed concretely: on a 1404×1872 page, decode() allocates a ~10.03 MB buffer; decodeAtScale(..., 10) allocates ~103.55 KB directly, without the full buffer ever existing.

RattaRLEDecoder.decodeAtScale(buffer, width, height, factor, palette?, allBlank?)

Like decode(), but samples directly at 1/factor resolution (nearest-neighbor: output (x, y) takes full-res pixel (x*factor, y*factor)) by walking the same RLE runs and only computing/writing output pixels that land on a sample point — skipping whole sample rows/columns arithmetically rather than iterating every full-resolution pixel a run covers, so a run's cost scales with the output pixels it covers, not the full-resolution ones.

decode()'s run-parsing loop (the two-byte length-extension "holder" logic) is now shared with decodeAtScale via a private _walkRuns helper, parameterized by how a run gets written (fillRun vs. the new fillRunAtScale). decode()'s own behavior is unchanged — only how it's factored — confirmed by the full existing test suite (46 tests across many real fixtures) plus a new byte-for-byte cross-check against decodeAtScale(..., 1).

toImage(note, pageNumbers?, { scale? })

scale (default 1) is a downsample factor; output pages are ceil(pageWidth/scale) × ceil(pageHeight/scale). The BGLAYER PNG path (user-uploaded background templates, decoded via decodePng rather than RattaRLEDecoder) is resized to match via image-js's own resize(), so compositeImages()'s equal-dimensions requirement still holds across every layer.

Test plan

  • npm run build (tsc) succeeds
  • npm run lint passes
  • npm test — full suite passes (46/46)
  • New tests/conversion.test.ts:
    • Hand-crafted 4×4→2×2 decodeAtScale case with exact expected pixels
    • decodeAtScale(..., 1) matches decode() byte-for-byte on a real layer's buffer (regression guard for the _walkRuns refactor)
    • decodeAtScale(..., 5) (a factor that doesn't evenly divide the page) matches a naive nearest-neighbor downsample of decode()'s full output, pixel-by-pixel, on a real layer's buffer
    • Invalid factor/scale (non-positive-integer) rejected
    • toImage(note, [1], { scale: 10 }) integration test — correct output dimensions, visually inspected the rendered thumbnail
  • Confirmed via a scratch script that decodeAtScale never allocates the full-resolution buffer: ~10.03 MB (decode()) vs. ~103.55 KB (decodeAtScale(..., 10)) on a real 1404×1872 page

Closes #40. toImage() always rasterized every layer at full
pageWidth x pageHeight, even for a small thumbnail -- the motivating
case (a ~140px sidebar preview in the Obsidian plugin) paid the same
decode/memory cost as actually viewing the page, contributing to
memory pressure on iOS.

Goes with the "real fix" approach flagged in the issue (decode-time
downsampling) rather than decode-full-then-resize, since only the
former avoids ever allocating the full-resolution buffer -- resizing
afterward still hits that peak allocation, which is what actually
matters for the motivating crash.

- RattaRLEDecoder.decodeAtScale(buffer, width, height, factor, ...):
  like decode(), but samples directly at 1/factor resolution
  (nearest-neighbor) by walking the same RLE runs and only writing
  output pixels that land on a sample point, skipping whole sample
  rows/columns arithmetically rather than iterating every
  full-resolution pixel a run covers. Never allocates a
  width x height buffer. On a 1404x1872 page, factor 10 produces a
  ~104 KB buffer instead of the ~10 MB decode() needs.
- decode()'s run-parsing loop (the two-byte length-extension "holder"
  logic) is now shared with decodeAtScale via a private _walkRuns
  helper, parameterized by how a run gets written; decode()'s own
  behavior is unchanged (only how it's factored), confirmed by the
  full existing test suite plus a new byte-for-byte cross-check
  against decodeAtScale(factor: 1).
- toImage(note, pageNumbers?, { scale? }): scale (default 1) is a
  downsample factor. Output pages are ceil(pageWidth/scale) x
  ceil(pageHeight/scale). The BGLAYER PNG path (user-uploaded
  background templates) is resized to match via image-js's resize()
  so compositeImages()' equal-dimensions requirement still holds
  across layers.

Tests: a hand-crafted decodeAtScale case, decode()-vs-decodeAtScale
cross-checks (factor 1 byte-for-byte; factor 5, which doesn't evenly
divide the page, pixel-by-pixel against a naive downsample of the
full decode) against a real fixture, invalid-factor/-scale rejection,
and a toImage({ scale }) integration test with visual inspection of
the output.
@philips
philips marked this pull request as ready for review July 31, 2026 17:10
@philips
philips merged commit 324113e into main Jul 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support rendering at a reduced resolution, for cheap thumbnails

2 participants