A fork of pulp-os for the xteink4 adding custom apps
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: proportional image display with inline scaling and precache fixes Replace the fixed IMAGE_DISPLAY_H (200px) cap with a proportional system that scales inline images to 40% of the text area while preserving aspect ratio, and decodes fullscreen images at the full text area budget. Image dimension pre-scan: - Add prescan_image_heights() to scan page buffers for IMG_REF markers and resolve each image's display height before wrapping - peek_cached_image_size() reads the 4-byte cache header (no pixel data) for already-decoded dimensions - peek_source_dimensions() peeks PNG IHDR (29 bytes) or JPEG SOF (up to 32KB header) directly from stored ZIP entries, computes the decoder's integer downscale output height; deflate-compressed entries fall back to DEFAULT_IMG_H (350px) Proportional line reservation: - wrap_proportional() takes an img_heights slice and reserves the exact number of lines each image needs at its actual height - Inline heights capped to inline_img_max_h() (40% of text area, ~308px on default theme); fullscreen bypass uses full budget Decode-time scaling: - decode_page_images() uses the inline budget for non-fullscreen pages, full text_area_h for fullscreen - Oversized cache hits (precached at full budget) are skipped and re-decoded at the inline budget so images are properly scaled rather than clipped Vertical centering: - Inline images are centered within their reserved line block instead of top-aligned, eliminating excess bottom margin Precache reliability: - Add work_queue::can_submit() to check channel capacity before expensive ZIP extraction; prevents wasted deflate + alloc when the worker channel is already full - Change queue-full race from permanent skip to retry on next poll - Add OOM retry for streaming decode: release ch_cache (~96KB) and retry once, matching the existing pattern in decode_page_images

hansmrtn 70aa004d 3f357bff

+372 -77
-1
Cargo.lock
··· 1485 1485 [[package]] 1486 1486 name = "smol-epub" 1487 1487 version = "0.1.0" 1488 - source = "git+https://github.com/hansmrtn/smol-epub#813cc03043ce379464dc2c838e3bd03dba951029" 1489 1488 dependencies = [ 1490 1489 "log", 1491 1490 "miniz_oxide",
+1 -1
Cargo.toml
··· 13 13 embedded-graphics-core = "0.4.1" 14 14 embedded-graphics = "0.8.2" 15 15 embedded-sdmmc = { git = "https://github.com/hansmrtn/embedded-sdmmc-rs", branch = "async", features = ["async"] } 16 - smol-epub = { git = "https://github.com/hansmrtn/smol-epub", features = ["async"] } 16 + smol-epub = { path = "../smol-epub", features = ["async"] } 17 17 critical-section = "1.2.0" 18 18 static_cell = "2.1.1" 19 19 nb = "1.1.0"
+15 -15
kernel/src/drivers/input.rs
··· 132 132 return self.queue.pop(); 133 133 } 134 134 135 - if let Some(btn) = self.stable { 136 - if !self.hold_consumed { 137 - let held = now - self.press_since; 135 + if let Some(btn) = self.stable 136 + && !self.hold_consumed 137 + { 138 + let held = now - self.press_since; 138 139 139 - if !self.long_press_fired && held >= Duration::from_millis(timing::LONG_PRESS_MS) { 140 - self.long_press_fired = true; 141 - self.last_repeat = now; 142 - log::info!("input: LongPress({:?}) after {}ms", btn, held.as_millis()); 143 - return Some(Event::LongPress(btn)); 144 - } 140 + if !self.long_press_fired && held >= Duration::from_millis(timing::LONG_PRESS_MS) { 141 + self.long_press_fired = true; 142 + self.last_repeat = now; 143 + log::info!("input: LongPress({:?}) after {}ms", btn, held.as_millis()); 144 + return Some(Event::LongPress(btn)); 145 + } 145 146 146 - if self.long_press_fired 147 - && (now - self.last_repeat) >= Duration::from_millis(timing::REPEAT_MS) 148 - { 149 - self.last_repeat = now; 150 - return Some(Event::Repeat(btn)); 151 - } 147 + if self.long_press_fired 148 + && (now - self.last_repeat) >= Duration::from_millis(timing::REPEAT_MS) 149 + { 150 + self.last_repeat = now; 151 + return Some(Event::Repeat(btn)); 152 152 } 153 153 } 154 154
+6 -6
kernel/src/kernel/app.rs
··· 210 210 Redraw::None => false, 211 211 Redraw::Full => true, 212 212 Redraw::Partial(_) => { 213 - self.immediate || self.coalesce_until.map_or(true, |t| Instant::now() >= t) 213 + self.immediate || self.coalesce_until.is_none_or(|t| Instant::now() >= t) 214 214 } 215 215 } 216 216 } ··· 230 230 // auto-marks the region dirty so the next render shows it. 231 231 pub fn set_loading(&mut self, region: Region, msg: &str, pct: u8) { 232 232 let n = msg.len().min(LOADING_BUF_SIZE); 233 - self.loading_buf[..n].copy_from_slice(&msg[..n].as_bytes()[..n]); 233 + self.loading_buf[..n].copy_from_slice(&msg.as_bytes()[..n]); 234 234 self.loading_len = n as u8; 235 235 self.loading_pct = pct.min(100); 236 236 self.loading_region = region; ··· 364 364 365 365 // check if an app ID is anywhere in the stack 366 366 pub fn contains(&self, id: Id) -> bool { 367 - self.stack[..self.depth].iter().any(|&i| i == id) 367 + self.stack[..self.depth].contains(&id) 368 368 } 369 369 370 370 // restore stack from saved session data ··· 373 373 where 374 374 F: Fn(u8) -> Id, 375 375 { 376 - self.depth = depth.min(MAX_STACK_DEPTH).max(1); 377 - for i in 0..self.depth { 378 - self.stack[i] = convert(stack[i]); 376 + self.depth = depth.clamp(1, MAX_STACK_DEPTH); 377 + for (i, &raw) in stack.iter().enumerate().take(self.depth) { 378 + self.stack[i] = convert(raw); 379 379 } 380 380 } 381 381
+2 -4
kernel/src/kernel/scheduler.rs
··· 446 446 if !power { 447 447 tasks::request_hold_reset(); 448 448 } 449 - } else if app_mgr.suppress_deferred_input() != suppressed_before { 450 - if !power { 451 - tasks::request_hold_reset(); 452 - } 449 + } else if app_mgr.suppress_deferred_input() != suppressed_before && !power { 450 + tasks::request_hold_reset(); 453 451 } 454 452 } 455 453 }
+8
kernel/src/kernel/work_queue.rs
··· 132 132 static WORK_IN: Channel<CriticalSectionRawMutex, WorkItem, 2> = Channel::new(); 133 133 static WORK_OUT: Channel<CriticalSectionRawMutex, WorkResult, 2> = Channel::new(); 134 134 135 + // true if the input channel has room for at least one more item. 136 + // use before expensive extraction to avoid wasted work when the 137 + // worker hasn't consumed its previous item yet. 138 + #[inline] 139 + pub fn can_submit() -> bool { 140 + !WORK_IN.is_full() 141 + } 142 + 135 143 pub fn submit(generation: u16, task: WorkTask) -> bool { 136 144 WORK_IN.try_send(WorkItem { generation, task }).is_ok() 137 145 }
+271 -34
src/apps/reader/images.rs
··· 19 19 use crate::kernel::KernelHandle; 20 20 use crate::kernel::work_queue; 21 21 22 - use super::{IMAGE_DISPLAY_H, NO_PREFETCH, PAGE_BUF, PRECACHE_IMG_MAX, ReaderApp}; 22 + use super::{ 23 + DEFAULT_IMG_H, MAX_IMAGES_PER_PAGE, NO_PREFETCH, PAGE_BUF, PRECACHE_IMG_MAX, ReaderApp, 24 + }; 23 25 24 26 // result of scanning a chapter for the next uncached image 25 27 enum ScanResult { ··· 99 101 let img_name = img_cache_name(cache::fnv1a(full_path.as_bytes())); 100 102 let img_file = img_cache_str(&img_name); 101 103 104 + // inline images are capped to a fraction of the text area so 105 + // they feel proportional to surrounding text. fullscreen 106 + // images (sole content on the page) get the full budget. 107 + let img_budget_h = if self.fullscreen_img { 108 + self.text_area_h 109 + } else { 110 + super::inline_img_max_h(self.text_area_h) 111 + }; 112 + 102 113 if let Ok(img) = load_cached_image(k, dir, img_file) { 114 + // use the cache if the image already fits the budget; 115 + // if the cached image is too tall (precache used full 116 + // text_area_h) fall through to re-decode at inline budget 117 + if img.height <= img_budget_h { 118 + log::info!( 119 + "reader: image cache hit {} ({}x{})", 120 + img_file, 121 + img.width, 122 + img.height 123 + ); 124 + self.page_img = Some(img); 125 + return; 126 + } 103 127 log::info!( 104 - "reader: image cache hit {} ({}x{})", 105 - img_file, 128 + "reader: cache {}x{} exceeds inline budget {}, re-decoding", 106 129 img.width, 107 - img.height 130 + img.height, 131 + img_budget_h, 108 132 ); 109 - self.page_img = Some(img); 110 - return; 133 + // drop the oversized image before decoding a smaller one 134 + drop(img); 111 135 } 112 136 113 137 // background precache will decode this image eventually; ··· 183 207 return; 184 208 } 185 209 186 - let img_max_h = if self.fullscreen_img { 187 - self.text_area_h 188 - } else { 189 - IMAGE_DISPLAY_H 190 - }; 210 + // decode at the context-appropriate budget: inline images use 211 + // the capped height so they scale proportionally; fullscreen 212 + // images use the full text area 213 + let img_max_h = img_budget_h; 191 214 192 215 let img_max_w = self.text_w as u16; 193 216 let do_decode = |k_ref: &mut KernelHandle<'_>| -> Result<DecodedImage, &'static str> { ··· 287 310 } 288 311 } 289 312 313 + // pre-scan the page buffer for IMG_REF markers and look up each 314 + // image's decoded dimensions (from cache or ZIP headers). 315 + // populates self.img_heights so wrap_proportional can reserve 316 + // the exact number of lines for each image. 317 + pub(super) fn prescan_image_heights(&mut self, k: &mut KernelHandle<'_>, buf_len: usize) { 318 + self.img_height_count = 0; 319 + 320 + if !self.is_epub || self.epub.spine.is_empty() { 321 + return; 322 + } 323 + 324 + let ch_zip_idx = self.epub.spine.items[self.epub.chapter as usize] as usize; 325 + let ch_path = self.epub.zip.entry_name(ch_zip_idx); 326 + let ch_dir = ch_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); 327 + 328 + let dir_buf = self.epub.cache_dir; 329 + let dir = cache::dir_name_str(&dir_buf); 330 + 331 + let (nb, nl) = self.name_copy(); 332 + let epub_name = core::str::from_utf8(&nb[..nl]).unwrap_or(""); 333 + 334 + let text_w = self.text_w; 335 + let text_area_h = self.text_area_h; 336 + let max_inline_h = super::inline_img_max_h(text_area_h); 337 + 338 + // scan for [MARKER, IMG_REF, len, path...] sequences 339 + let mut i = 0usize; 340 + while i + 2 < buf_len && (self.img_height_count as usize) < MAX_IMAGES_PER_PAGE { 341 + if self.pg.buf[i] != MARKER || self.pg.buf[i + 1] != IMG_REF { 342 + i += 1; 343 + continue; 344 + } 345 + let path_len = self.pg.buf[i + 2] as usize; 346 + let path_start = i + 3; 347 + if path_len == 0 || path_start + path_len > buf_len { 348 + i += 1; 349 + continue; 350 + } 351 + 352 + // resolve image path 353 + let mut src_buf = [0u8; 128]; 354 + let src_n = path_len.min(src_buf.len()); 355 + src_buf[..src_n].copy_from_slice(&self.pg.buf[path_start..path_start + src_n]); 356 + let src_str = match core::str::from_utf8(&src_buf[..src_n]) { 357 + Ok(s) if !s.is_empty() => s, 358 + _ => { 359 + self.img_heights[self.img_height_count as usize] = DEFAULT_IMG_H; 360 + self.img_height_count += 1; 361 + i = path_start + path_len; 362 + continue; 363 + } 364 + }; 365 + 366 + let mut path_buf = [0u8; 512]; 367 + let plen = epub::resolve_path(ch_dir, src_str, &mut path_buf); 368 + let full_path = match core::str::from_utf8(&path_buf[..plen]) { 369 + Ok(s) => s, 370 + Err(_) => { 371 + self.img_heights[self.img_height_count as usize] = DEFAULT_IMG_H; 372 + self.img_height_count += 1; 373 + i = path_start + path_len; 374 + continue; 375 + } 376 + }; 377 + 378 + let path_hash = cache::fnv1a(full_path.as_bytes()); 379 + let img_name = img_cache_name(path_hash); 380 + let img_file = img_cache_str(&img_name); 381 + 382 + // try 1: read cached image header (4 bytes, very fast) 383 + let out_h = if let Some((_w, h)) = peek_cached_image_size(k, dir, img_file) { 384 + // cached image is already at the final decoded size 385 + h 386 + } else { 387 + // try 2: peek source dimensions from the ZIP entry 388 + peek_source_dimensions(k, epub_name, &self.epub.zip, full_path, text_w, text_area_h) 389 + }; 390 + 391 + // cap to the inline budget; fullscreen images bypass line 392 + // reservation entirely and use the full text_area_h 393 + self.img_heights[self.img_height_count as usize] = out_h.min(max_inline_h); 394 + self.img_height_count += 1; 395 + i = path_start + path_len; 396 + } 397 + } 398 + 290 399 // scan one chapter from start_offset for the first uncached image. 291 400 // reads chapter data in chunks via self.prefetch, finds IMG_REF 292 401 // markers, resolves paths against the ZIP, checks the SD cache, ··· 415 524 full_path, 416 525 entry.uncomp_size, 417 526 ); 418 - match decode_image_streaming( 419 - k, 420 - epub_name, 421 - &entry, 422 - is_jpeg, 423 - self.text_w as u16, 424 - self.text_area_h, 425 - ) { 527 + let img_w = self.text_w as u16; 528 + let img_h = self.text_area_h; 529 + let result = 530 + decode_image_streaming(k, epub_name, &entry, is_jpeg, img_w, img_h); 531 + 532 + // OOM fallback: release chapter cache and retry 533 + let result = match result { 534 + Ok(img) => Ok(img), 535 + Err(e) if !self.epub.ch_cache.is_empty() => { 536 + log::info!( 537 + "precache: streaming failed ({}), releasing {} KB ch_cache and retrying", 538 + e, 539 + self.epub.ch_cache.len() / 1024, 540 + ); 541 + self.epub.ch_cache = Vec::new(); 542 + decode_image_streaming(k, epub_name, &entry, is_jpeg, img_w, img_h) 543 + } 544 + Err(e) => Err(e), 545 + }; 546 + 547 + match result { 426 548 Ok(img) => { 427 549 log::info!( 428 550 "precache: decoded {}x{} ({}B)", ··· 442 564 }); 443 565 } 444 566 445 - // wait for worker to have capacity before expensive 446 - // extraction; caller sees Dispatched + worker busy 447 - // and transitions to WaitImage, retrying after drain 448 - if !work_queue::is_idle() { 567 + // wait for worker to have capacity before the 568 + // expensive extraction (deflate + alloc). check both 569 + // idle state and channel room to avoid extracting data 570 + // that can't be submitted. 571 + if !work_queue::is_idle() || !work_queue::can_submit() { 449 572 return Ok(ScanResult::Dispatched { 450 573 resume_offset: (offset + i) as u32, 451 574 }); ··· 476 599 resume_offset: resume, 477 600 }); 478 601 } 479 - // queue full despite idle check; skip this image, 480 - // it will be decoded on demand if the user views it 481 - log::warn!("precache: worker queue full, skipping {}", full_path); 482 - i = path_start + path_len; 483 - continue; 602 + // rare race: channel filled between can_submit() and 603 + // submit(). retry on next poll instead of skipping. 604 + log::info!("precache: queue race, will retry {}", full_path); 605 + return Ok(ScanResult::Dispatched { 606 + resume_offset: (offset + i) as u32, 607 + }); 484 608 } 485 609 486 610 // advance with overlap so markers at chunk boundaries are not missed ··· 595 719 let r = self.epub.chapter as usize; 596 720 let spine_len = self.epub.spine.len(); 597 721 for &ch in &[r, r + 1, r.saturating_sub(1), r + 2, r.saturating_sub(2)] { 598 - if ch < spine_len && self.epub.ch_cached[ch] { 599 - if self.dispatch_one_image_in_chapter(k, ch) { 600 - return true; 601 - } 722 + if ch < spine_len 723 + && self.epub.ch_cached[ch] 724 + && self.dispatch_one_image_in_chapter(k, ch) 725 + { 726 + return true; 602 727 } 603 728 } 604 729 false ··· 607 732 608 733 pub(super) fn img_cache_name(hash: u32) -> [u8; 12] { 609 734 let mut n = *b"00000000.BIN"; 610 - for i in 0..8 { 735 + for (i, byte) in n.iter_mut().enumerate().take(8) { 611 736 let nibble = ((hash >> (28 - i * 4)) & 0xF) as u8; 612 - n[i] = if nibble < 10 { 737 + *byte = if nibble < 10 { 613 738 b'0' + nibble 614 739 } else { 615 740 b'A' + nibble - 10 ··· 735 860 data, 736 861 stride, 737 862 }) 863 + } 864 + 865 + // read just the 4-byte header of a cached 1-bit image file to 866 + // extract its decoded dimensions without loading the pixel data. 867 + // returns None if the file doesn't exist, is too small, or has 868 + // zero dimensions. 869 + fn peek_cached_image_size(k: &mut KernelHandle<'_>, dir: &str, name: &str) -> Option<(u16, u16)> { 870 + let size = k.file_size_app_subdir(dir, name).ok()?; 871 + if size < 5 { 872 + return None; 873 + } 874 + let mut hdr = [0u8; 4]; 875 + k.read_app_subdir_chunk(dir, name, 0, &mut hdr).ok()?; 876 + let w = u16::from_le_bytes([hdr[0], hdr[1]]); 877 + let h = u16::from_le_bytes([hdr[2], hdr[3]]); 878 + if w == 0 || h == 0 { 879 + return None; 880 + } 881 + Some((w, h)) 882 + } 883 + 884 + // resolve a ZIP image entry's source dimensions and compute the 885 + // scaled output height that the decoder would produce. 886 + // 887 + // for stored (uncompressed) entries, peeks the source dimensions 888 + // directly from the ZIP stream (29 bytes for PNG, up to 32 KB for 889 + // JPEG). for deflate-compressed entries, we can't cheaply read the 890 + // raw pixels, so we return DEFAULT_IMG_H as a reasonable fallback 891 + // (the actual decode will happen later and may produce a different 892 + // height, but it's close enough for line reservation). 893 + fn peek_source_dimensions( 894 + k: &mut KernelHandle<'_>, 895 + epub_name: &str, 896 + zip: &ZipIndex, 897 + full_path: &str, 898 + text_w: u32, 899 + text_area_h: u16, 900 + ) -> u16 { 901 + let zip_idx = match zip.find(full_path).or_else(|| zip.find_icase(full_path)) { 902 + Some(idx) => idx, 903 + None => return DEFAULT_IMG_H, 904 + }; 905 + let entry = *zip.entry(zip_idx); 906 + 907 + // deflate-compressed images: can't peek dimensions cheaply 908 + if entry.method != zip::METHOD_STORED { 909 + return DEFAULT_IMG_H; 910 + } 911 + 912 + // read local header to find data offset 913 + let data_offset = { 914 + let mut hdr = [0u8; 30]; 915 + if k.read_chunk(epub_name, entry.local_offset, &mut hdr) 916 + .is_err() 917 + { 918 + return DEFAULT_IMG_H; 919 + } 920 + match ZipIndex::local_header_data_skip(&hdr) { 921 + Ok(skip) => entry.local_offset + skip, 922 + Err(_) => return DEFAULT_IMG_H, 923 + } 924 + }; 925 + 926 + let is_jpeg = is_image_ext_jpeg(full_path); 927 + let is_png = is_image_ext_png(full_path); 928 + 929 + // fall back to magic-byte detection if extension is ambiguous 930 + let (is_jpeg, is_png) = if is_jpeg || is_png { 931 + (is_jpeg, is_png) 932 + } else { 933 + let mut magic = [0u8; 8]; 934 + let n = k 935 + .read_chunk(epub_name, data_offset, &mut magic) 936 + .unwrap_or(0); 937 + ( 938 + n >= 2 && magic[0] == 0xFF && magic[1] == 0xD8, 939 + n >= 8 && magic[..8] == [137, 80, 78, 71, 13, 10, 26, 10], 940 + ) 941 + }; 942 + 943 + let read_err = |_: crate::error::Error| -> &'static str { "read failed" }; 944 + 945 + let dims = if is_png { 946 + smol_epub::png::peek_png_dimensions_streaming( 947 + |off, buf| k.read_chunk(epub_name, off, buf).map_err(read_err), 948 + data_offset, 949 + entry.uncomp_size, 950 + ) 951 + .map(|(w, h)| (w as u16, h as u16)) 952 + } else if is_jpeg { 953 + smol_epub::jpeg::peek_jpeg_dimensions_streaming( 954 + |off, buf| k.read_chunk(epub_name, off, buf).map_err(read_err), 955 + data_offset, 956 + entry.uncomp_size, 957 + ) 958 + } else { 959 + return DEFAULT_IMG_H; 960 + }; 961 + 962 + match dims { 963 + Ok((src_w, src_h)) if src_w > 0 && src_h > 0 => { 964 + // replicate the decoder's integer downscale logic: 965 + // scale = max(ceil(src_w/max_w), ceil(src_h/max_h), 1) 966 + let max_w = text_w as u16; 967 + let max_h = text_area_h; 968 + let sw = src_w.div_ceil(max_w); 969 + let sh = src_h.div_ceil(max_h); 970 + let scale = sw.max(sh).max(1); 971 + src_h / scale 972 + } 973 + _ => DEFAULT_IMG_H, 974 + } 738 975 } 739 976 740 977 pub(super) fn save_cached_image(
+49 -6
src/apps/reader/mod.rs
··· 73 73 74 74 pub(super) const INDENT_PX: u32 = 24; 75 75 76 - pub(super) const IMAGE_DISPLAY_H: u16 = 200; 76 + // max inline images tracked per page buffer for dimension pre-scan 77 + pub(super) const MAX_IMAGES_PER_PAGE: usize = 8; 78 + 79 + // default image height budget (half text area) used when actual 80 + // dimensions are unavailable (e.g. uncached deflated images, or 81 + // during preindex_all_pages where no pre-scan runs) 82 + pub(super) const DEFAULT_IMG_H: u16 = 350; 83 + 84 + // inline images are capped at this fraction of the text area height. 85 + // keeps illustrations proportional to surrounding text, similar to 86 + // Kindle / Apple Books. fullscreen images (sole content on a page) 87 + // are not affected — they use the full text_area_h budget. 88 + pub(super) const INLINE_IMG_MAX_PCT: u16 = 40; 89 + 90 + #[inline] 91 + pub(super) fn inline_img_max_h(text_area_h: u16) -> u16 { 92 + ((text_area_h as u32 * INLINE_IMG_MAX_PCT as u32) / 100) as u16 93 + } 77 94 78 95 pub(super) const CHAPTER_CACHE_MAX: usize = 98304; 79 96 ··· 311 328 pub(super) text_area_h: u16, // height of text area (SCREEN_H - text_y - bottom_pad) 312 329 pub(super) reading_theme_idx: u8, 313 330 331 + // pre-scanned image heights for the current page buffer; 332 + // populated before wrapping so the pager can reserve the exact 333 + // number of lines each image needs at its natural aspect ratio 334 + pub(super) img_heights: [u16; MAX_IMAGES_PER_PAGE], 335 + pub(super) img_height_count: u8, 336 + 314 337 pub(super) book_font_size_idx: u8, 315 338 pub(super) applied_font_idx: u8, 316 339 ··· 353 376 text_w: TEXT_W, 354 377 text_area_h: TEXT_AREA_H, 355 378 reading_theme_idx: 0, 379 + 380 + img_heights: [0u16; MAX_IMAGES_PER_PAGE], 381 + img_height_count: 0, 356 382 357 383 book_font_size_idx: 0, 358 384 applied_font_idx: 0, ··· 1486 1512 if let Some(ref img) = self.page_img { 1487 1513 let img_x = self.text_margin as i32 1488 1514 + ((self.text_w as i32 - img.width as i32) / 2).max(0); 1489 - // clamp to remaining vertical space so images near 1490 - // the bottom of a page are never cut off 1515 + 1516 + // count reserved image lines for vertical centering 1517 + let mut img_line_count = 0i32; 1518 + for j in i..self.pg.line_count { 1519 + if self.pg.lines[j].is_image() { 1520 + img_line_count += 1; 1521 + } else { 1522 + break; 1523 + } 1524 + } 1525 + let reserved_h = img_line_count * line_h; 1526 + 1527 + // the image is already decoded at the correct 1528 + // budget (inline or fullscreen); just clamp to 1529 + // remaining vertical space as a safety net 1491 1530 let space_below = 1492 - (self.text_area_h as i32 - i as i32 * line_h).max(0) as usize; 1493 - let blit_h = (img.height as usize).min(space_below); 1531 + (self.text_area_h as i32 - i as i32 * line_h).max(0); 1532 + let blit_h = (img.height as i32).min(space_below).max(0) as usize; 1533 + 1534 + // center vertically within reserved lines 1535 + let y_offset = ((reserved_h - blit_h as i32) / 2).max(0); 1536 + 1494 1537 strip.blit_1bpp( 1495 1538 &img.data, 1496 1539 0, ··· 1498 1541 blit_h, 1499 1542 img.stride, 1500 1543 img_x, 1501 - y_top, 1544 + y_top + y_offset, 1502 1545 true, 1503 1546 ); 1504 1547 img_rendered = true;
+16 -3
src/apps/reader/paging.rs
··· 10 10 use crate::kernel::KernelHandle; 11 11 12 12 use super::{ 13 - IMAGE_DISPLAY_H, INDENT_PX, LINES_PER_PAGE, LineSpan, MAX_PAGES, NO_PREFETCH, PAGE_BUF, 13 + DEFAULT_IMG_H, INDENT_PX, LINES_PER_PAGE, LineSpan, MAX_PAGES, NO_PREFETCH, PAGE_BUF, 14 14 ReaderApp, State, decode_utf8_char, 15 15 }; 16 16 ··· 19 19 let fonts_copy = self.fonts; 20 20 21 21 if let Some(fs) = fonts_copy { 22 + let heights = &self.img_heights[..self.img_height_count as usize]; 22 23 let (c, count) = wrap_proportional( 23 24 &self.pg.buf, 24 25 n, ··· 26 27 &mut self.pg.lines, 27 28 self.max_lines, 28 29 self.text_w, 30 + heights, 29 31 ); 30 32 self.pg.line_count = count; 31 33 c ··· 116 118 self.pg.buf_len = n; 117 119 self.pg.prefetch_page = NO_PREFETCH; 118 120 self.pg.prefetch_len = 0; 121 + self.prescan_image_heights(k, n); 119 122 self.wrap_lines_counted(n); 120 123 self.decode_page_images(k); 121 124 return Ok(()); ··· 157 160 self.pg.buf_len = n; 158 161 } 159 162 163 + self.prescan_image_heights(k, self.pg.buf_len); 160 164 let consumed = self.wrap_lines_counted(self.pg.buf_len); 161 165 let next_offset = self.pg.offsets[self.pg.page] + consumed as u32; 162 166 ··· 376 380 lines: &mut [LineSpan], 377 381 max_lines: usize, 378 382 max_width_px: u32, 383 + img_heights: &[u16], 379 384 ) -> (usize, usize) { 380 385 let max_l = max_lines.min(lines.len()); 381 386 let base_max_w = max_width_px; ··· 390 395 let mut heading = false; 391 396 let mut indent: u8 = 0; 392 397 let mut max_w = base_max_w; 398 + let mut img_idx: usize = 0; 393 399 394 400 #[inline] 395 401 fn current_style(bold: bool, italic: bool, heading: bool) -> fonts::Style { ··· 436 442 } 437 443 438 444 let line_h = fonts.line_height(fonts::Style::Regular); 439 - // ceiling division: ensure reserved lines fully cover IMAGE_DISPLAY_H 440 - let img_lines = ((IMAGE_DISPLAY_H + line_h - 1) / line_h).max(1) as usize; 445 + // use pre-scanned height if available, else default 446 + let img_h = if img_idx < img_heights.len() && img_heights[img_idx] > 0 { 447 + img_heights[img_idx] 448 + } else { 449 + DEFAULT_IMG_H 450 + }; 451 + img_idx += 1; 452 + // ceiling division: ensure reserved lines fully cover image height 453 + let img_lines = img_h.div_ceil(line_h).max(1) as usize; 441 454 442 455 if line_count < max_l { 443 456 lines[line_count] = LineSpan {
+3 -5
src/apps/settings.rs
··· 142 142 fn visible_items(&self) -> usize { 143 143 let avail = SCREEN_H.saturating_sub(self.items_top + BUTTON_BAR_H); 144 144 let count = (avail / ROW_STRIDE) as usize; 145 - count.max(1).min(NUM_ITEMS) 145 + count.clamp(1, NUM_ITEMS) 146 146 } 147 147 148 148 // item labels and values: ··· 423 423 return; 424 424 } 425 425 426 - if self.save_needed { 427 - if self.save(k) { 428 - self.save_needed = false; 429 - } 426 + if self.save_needed && self.save(k) { 427 + self.save_needed = false; 430 428 } 431 429 } 432 430
+1 -2
src/apps/upload.rs
··· 440 440 441 441 let _ = socket.write_all(b"[").await; 442 442 let mut json_buf = [0u8; 80]; // per-entry scratch: {"name":"XXXXXXXX.XXX","size":4294967295} 443 - for i in 0..count { 444 - let e = &entries[i]; 443 + for (i, e) in entries.iter().enumerate().take(count) { 445 444 let name = e.name_str(); 446 445 let mut pos = 0usize; 447 446 let prefix = b"{\"name\":\"";