Support decoding at a reduced resolution, for cheap thumbnails - #41
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #40.
toImage()always rasterizes every layer at fullpageWidth×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 at1/factorresolution (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 withdecodeAtScalevia a private_walkRunshelper, parameterized by how a run gets written (fillRunvs. the newfillRunAtScale).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 againstdecodeAtScale(..., 1).toImage(note, pageNumbers?, { scale? })scale(default1) is a downsample factor; output pages areceil(pageWidth/scale)×ceil(pageHeight/scale). TheBGLAYERPNG path (user-uploaded background templates, decoded viadecodePngrather thanRattaRLEDecoder) is resized to match viaimage-js's ownresize(), socompositeImages()'s equal-dimensions requirement still holds across every layer.Test plan
npm run build(tsc) succeedsnpm run lintpassesnpm test— full suite passes (46/46)tests/conversion.test.ts:decodeAtScalecase with exact expected pixelsdecodeAtScale(..., 1)matchesdecode()byte-for-byte on a real layer's buffer (regression guard for the_walkRunsrefactor)decodeAtScale(..., 5)(a factor that doesn't evenly divide the page) matches a naive nearest-neighbor downsample ofdecode()'s full output, pixel-by-pixel, on a real layer's bufferfactor/scale(non-positive-integer) rejectedtoImage(note, [1], { scale: 10 })integration test — correct output dimensions, visually inspected the rendered thumbnaildecodeAtScalenever allocates the full-resolution buffer: ~10.03 MB (decode()) vs. ~103.55 KB (decodeAtScale(..., 10)) on a real 1404×1872 page