diff --git a/Cargo.lock b/Cargo.lock index 7961ede..29e26c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -193,6 +199,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bindgen" version = "0.72.1" @@ -249,6 +261,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "byteorder-lite" @@ -527,6 +553,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "derive_more" version = "2.1.1" @@ -621,6 +653,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "exr" version = "1.74.0" @@ -704,12 +745,49 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + [[package]] name = "futures-core" version = "0.3.33" @@ -790,6 +868,18 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags", + "bytemuck", + "read-fonts", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -883,6 +973,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + [[package]] name = "imgref" version = "1.12.1" @@ -984,6 +1080,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lebe" version = "0.5.3" @@ -1092,6 +1200,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1324,6 +1441,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1349,6 +1472,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1542,6 +1674,17 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1591,11 +1734,47 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + [[package]] name = "rgb" version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] [[package]] name = "rustc-hash" @@ -1648,7 +1827,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" name = "rview" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "clap", "crossterm 0.29.0", "directories", @@ -1659,6 +1838,7 @@ dependencies = [ "nucleo-matcher", "ratatui", "rayon", + "resvg", "serde", "toml", "trash", @@ -1774,12 +1954,46 @@ dependencies = [ "quote", ] +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -1798,6 +2012,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1826,6 +2049,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "2.0.117" @@ -1902,6 +2135,38 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "toml" version = "0.8.23" @@ -1985,12 +2250,24 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -2008,6 +2285,12 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.1.14" @@ -2026,6 +2309,33 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64 0.23.1", + "data-url", + "fontdb", + "harfrust", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "simplecss", + "siphasher", + "skrifa", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf8parse" version = "0.2.2" @@ -2049,6 +2359,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2346,6 +2662,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "y4m" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index a42117f..581a21c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ homepage = "https://github.com/jrf/rview" [dependencies] image = "0.25" +resvg = { version = "0.48", default-features = false, features = ["text", "system-fonts", "memmap-fonts", "raster-images"] } clap = { version = "4", features = ["derive"] } crossterm = "0.29" ratatui = "0.29" diff --git a/README.md b/README.md index 7c2928a..28b7a0c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ cargo install --path . --features video # requires ffmpeg 7+ rview # current directory rview ~/photos rview image.png # single file opens fullscreen +rview drawing.svg # SVG files also work in the gallery rview photo.jpg screenshot.png rview -t ~/.config/themes/nord.toml # theme file, or a catalog name rview -j 4 ~/photos # decode threads (default: all cores) @@ -45,7 +46,11 @@ Themes load from `~/.config/rview/config.toml` (`theme`, `theme_catalog`). `-t` ## Formats -**Images:** PNG, JPEG, GIF, WebP, BMP, TIFF, ICO, AVIF. +**Images:** PNG, JPEG, GIF, WebP, BMP, TIFF, ICO, AVIF, SVG. + +SVGs render as static images with transparency, system fonts, and local image references resolved relative to the SVG file. Thumbnails rasterize at the target size; fullscreen fit does not upscale beyond the SVG's intrinsic dimensions. Zoom and pan re-render the vector image at the visible viewport's pixel resolution, preserving detail instead of enlarging an intrinsic-resolution raster. + +SVG thumbnails are cached only in memory so changes to referenced files appear when reopening the gallery. SVG raster buffers use the same 512 MiB allocation limit as the standard image decoder. Zoom without filters allocates only the visible viewport, so oversized intrinsic dimensions do not require oversized raster buffers. SVG filters render on a full zoomed canvas before cropping, preserving effects whose inputs lie outside the viewport; that canvas and the visible crop share the allocation limit. **Video** (`--features video`): MP4, MOV, MKV, AVI, WebM, M4V. Playback loops at up to 10 fps. Gallery thumbnails use the first frame. diff --git a/src/app.rs b/src/app.rs index fc64c97..8b23249 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use std::collections::HashSet; use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{OnceLock, mpsc}; +use std::sync::{Arc, LazyLock, OnceLock, mpsc}; /// Multiplier applied on each zoom step. const ZOOM_STEP: f64 = 1.25; @@ -43,6 +43,31 @@ pub struct ThemePickerState { pub visible_height: usize, } +enum ZoomSource { + Raster(DynamicImage), + Svg(resvg::usvg::Tree), +} + +impl ZoomSource { + fn dimensions(&self) -> (f64, f64) { + match self { + Self::Raster(image) => (f64::from(image.width()), f64::from(image.height())), + Self::Svg(tree) => ( + f64::from(tree.size().width()), + f64::from(tree.size().height()), + ), + } + } +} + +struct ZoomCrop { + width: f64, + height: f64, + max_x: f64, + max_y: f64, + scale: f64, +} + pub struct App { pub images: Vec, pub filenames: Vec, @@ -85,10 +110,10 @@ pub struct App { pan_x: f64, pan_y: f64, zoom_dirty: bool, - /// Full-resolution decoded source for the current fullscreen image, used for zoomed crops. - source: Option, + /// Original raster pixels or a parsed SVG retained for rendering zoomed views. + source: Option, source_for: Option, - source_rx: Option>>, + source_rx: Option>>, source_target: Option, #[cfg(feature = "video")] @@ -519,11 +544,16 @@ impl App { self.source_target = Some(idx); let path = self.images[idx].clone(); rayon::spawn(move || { - let _ = tx.send(decode_image_with_hint(&path, None)); + let source = if is_svg(&path) { + parse_svg(&path).map(ZoomSource::Svg) + } else { + decode_image_with_hint(&path, None).map(ZoomSource::Raster) + }; + let _ = tx.send(source); }); } - /// Receive an async full-resolution source decode for zooming. + /// Receive an async raster decode or SVG parse for zooming. pub fn poll_source(&mut self) -> bool { let result = self.source_rx.as_ref().and_then(|rx| rx.try_recv().ok()); if let Some(result) = result { @@ -905,20 +935,25 @@ impl App { } pub fn load_if_needed(&mut self) { - // Zoomed view: crop the full-resolution source around the pan point and - // resize that crop to fill the viewport. + // Zoom from the original raster pixels or render the retained SVG vectors. if self.zoom > 1.0 { if self.source_for == Some(self.current) { if self.zoom_dirty - || self.loaded.is_none() + || (self.loaded.is_none() && self.error.is_none()) || self.loaded_for_rect != self.image_rect { - if let Some(view) = self.build_zoom_view() { - self.loaded = Some(view); - self.error = None; - self.loaded_for_rect = self.image_rect; - self.zoom_dirty = false; + match self.build_zoom_view() { + Ok(view) => { + self.loaded = Some(view); + self.error = None; + } + Err(error) => { + self.loaded = None; + self.error = Some(error.to_string()); + } } + self.loaded_for_rect = self.image_rect; + self.zoom_dirty = false; } return; } @@ -957,35 +992,82 @@ impl App { self.start_fullscreen_decode(self.current, self.image_rect); } - /// Build the zoomed, panned crop of the current full-resolution source, sized - /// to fill the viewport. Returns `None` when no source is available. - fn build_zoom_view(&self) -> Option { - let src = self.source.as_ref()?; - let (ow, oh) = (src.width(), src.height()); - if ow == 0 || oh == 0 { - return None; + fn viewport_pixels(&self) -> (u32, u32) { + ( + (self.image_rect.width as u32 * self.cell_px.0).max(1), + (self.image_rect.height as u32 * self.cell_px.1).max(1), + ) + } + + /// Source crop geometry used by zoom rendering. + fn zoom_crop(&self, src: &ZoomSource, vw: u32, vh: u32) -> ZoomCrop { + let (ow, oh) = src.dimensions(); + let fit = f64::min(f64::from(vw) / ow, f64::from(vh) / oh); + let scale = fit.min(1.0) * self.zoom; + let (width, height) = match src { + ZoomSource::Raster(_) => ( + (f64::from(vw) / scale).round().clamp(1.0, ow), + (f64::from(vh) / scale).round().clamp(1.0, oh), + ), + // Vector crops have no source-pixel grid or minimum one-pixel extent. + ZoomSource::Svg(_) => ( + (f64::from(vw) / scale).min(ow), + (f64::from(vh) / scale).min(oh), + ), + }; + ZoomCrop { + width, + height, + max_x: ow - width, + max_y: oh - height, + scale, } + } - let vw = (self.image_rect.width as u32 * self.cell_px.0).max(1); - let vh = (self.image_rect.height as u32 * self.cell_px.1).max(1); - - // Fit-to-window scale, never upscaling the base image (matches zoom == 1.0). - let fit = f64::min(vw as f64 / ow as f64, vh as f64 / oh as f64); - let base = fit.min(1.0); - let eff = base * self.zoom; - - // Source-pixel crop window that maps onto the viewport at the effective scale. - let crop_w = ((vw as f64 / eff).round() as u32).clamp(1, ow); - let crop_h = ((vh as f64 / eff).round() as u32).clamp(1, oh); - let max_x = ow.saturating_sub(crop_w); - let max_y = oh.saturating_sub(crop_h); - let x = (self.pan_x * max_x as f64).round() as u32; - let y = (self.pan_y * max_y as f64).round() as u32; - - let cropped = src.crop_imm(x, y, crop_w, crop_h); - let dst_w = ((crop_w as f64 * eff).round() as u32).max(1).min(vw); - let dst_h = ((crop_h as f64 * eff).round() as u32).max(1).min(vh); - Some(resize_to_exact(&cropped, dst_w, dst_h)) + /// Render a zoomed, panned viewport without rasterizing SVGs at intrinsic size. + fn build_zoom_view(&self) -> io::Result { + let src = self + .source + .as_ref() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "zoom source is not loaded"))?; + let (ow, oh) = src.dimensions(); + if ow == 0.0 || oh == 0.0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid image dimensions", + )); + } + let (vw, vh) = self.viewport_pixels(); + let crop = self.zoom_crop(src, vw, vh); + let x = self.pan_x * crop.max_x; + let y = self.pan_y * crop.max_y; + let dst_w = ((crop.width * crop.scale).round() as u32).max(1).min(vw); + let dst_h = ((crop.height * crop.scale).round() as u32).max(1).min(vh); + + match src { + ZoomSource::Raster(image) => { + let cropped = image.crop_imm( + x.round() as u32, + y.round() as u32, + crop.width as u32, + crop.height as u32, + ); + Ok(resize_to_exact(&cropped, dst_w, dst_h)) + } + ZoomSource::Svg(tree) => render_svg_viewport( + tree, + dst_w, + dst_h, + resvg::tiny_skia::Transform::from_row( + crop.scale as f32, + 0.0, + 0.0, + crop.scale as f32, + -(x * crop.scale) as f32, + -(y * crop.scale) as f32, + ), + ), + } } } @@ -1061,10 +1143,19 @@ pub(crate) fn resize_to_exact(img: &DynamicImage, dst_w: u32, dst_h: u32) -> Rgb RgbaImage::from_raw(dst_w, dst_h, dst_image.into_vec()).unwrap_or_else(|| img.to_rgba8()) } +fn is_svg(path: &Path) -> bool { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("svg")) +} + pub(crate) fn decode_image_with_hint( path: &Path, target: Option<(u32, u32)>, ) -> io::Result { + if is_svg(path) { + return decode_svg(path, target); + } + // libjpeg-turbo is the fast path, but it rejects some JPEG variants the // pure-Rust decoder accepts (and vice versa), so a turbo failure falls // through to `image` rather than aborting. Remember turbo's error so that if @@ -1103,6 +1194,120 @@ pub(crate) fn decode_image_with_hint( } } +fn parse_svg(path: &Path) -> io::Result { + use resvg::usvg; + + static FONTS: LazyLock> = LazyLock::new(|| { + let mut fonts = usvg::fontdb::Database::new(); + fonts.load_system_fonts(); + Arc::new(fonts) + }); + let options = usvg::Options { + resources_dir: path.parent().map(Path::to_path_buf), + fontdb: Arc::clone(&FONTS), + ..usvg::Options::default() + }; + let data = std::fs::read(path)?; + usvg::Tree::from_data(&data, &options) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn decode_svg(path: &Path, target: Option<(u32, u32)>) -> io::Result { + let tree = parse_svg(path)?; + let size = tree.size(); + // Match raster fit-to-window behavior: preserve aspect ratio, never upscale. + let scale = match target { + Some((w, h)) if w > 0 && h > 0 => (w as f32 / size.width()) + .min(h as f32 / size.height()) + .min(1.0), + _ => 1.0, + }; + let width = ((size.width() * scale) as u32).max(1); + let height = ((size.height() * scale) as u32).max(1); + render_svg( + &tree, + width, + height, + resvg::tiny_skia::Transform::from_scale(scale, scale), + None, + ) + .map(DynamicImage::ImageRgba8) +} + +/// Keep filter inputs outside the visible crop available to resvg's layer renderer. +fn render_svg_viewport( + tree: &resvg::usvg::Tree, + width: u32, + height: u32, + mut transform: resvg::tiny_skia::Transform, +) -> io::Result { + if tree.filters().is_empty() { + return render_svg(tree, width, height, transform, None); + } + + // resvg bounds intermediate filter layers relative to the destination canvas. + // Render the scaled document before cropping so off-screen inputs can contribute + // to visible effects. Integer padding preserves the viewport's subpixel phase. + let left = (-transform.tx).ceil().max(0.0) as u32; + let top = (-transform.ty).ceil().max(0.0) as u32; + let crop = resvg::tiny_skia::IntRect::from_xywh(left as i32, top as i32, width, height) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid SVG crop dimensions"))?; + transform.tx += left as f32; + transform.ty += top as f32; + let full_width = ((tree.size().width() * transform.sx + transform.tx).ceil() as u32) + .max(crop.right() as u32); + let full_height = ((tree.size().height() * transform.sy + transform.ty).ceil() as u32) + .max(crop.bottom() as u32); + render_svg(tree, full_width, full_height, transform, Some(crop)) +} + +fn render_svg( + tree: &resvg::usvg::Tree, + width: u32, + height: u32, + transform: resvg::tiny_skia::Transform, + crop: Option, +) -> io::Result { + use resvg::tiny_skia; + + // Apply the raster decoder's allocation budget before tiny-skia allocates. + let mut limits = image::Limits::default(); + limits + .reserve_buffer(width, height, image::ColorType::Rgba8) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if let Some(crop) = crop { + limits + .reserve_buffer(crop.width(), crop.height(), image::ColorType::Rgba8) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + } + let mut pixmap = tiny_skia::Pixmap::new(width, height).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "invalid SVG image dimensions") + })?; + resvg::render(tree, transform, &mut pixmap.as_mut()); + let pixmap = if let Some(crop) = crop { + let viewport = pixmap.clone_rect(crop).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "invalid SVG crop dimensions") + })?; + drop(pixmap); + viewport + } else { + pixmap + }; + let (width, height) = (pixmap.width(), pixmap.height()); + let mut pixels = pixmap.take(); + // tiny-skia produces premultiplied RGBA; image and Kitty need straight alpha. + for pixel in pixels.chunks_exact_mut(4) { + let alpha = u32::from(pixel[3]); + if alpha > 0 && alpha < 255 { + for channel in &mut pixel[..3] { + *channel = ((u32::from(*channel) * 255 + alpha / 2) / alpha) as u8; + } + } + } + RgbaImage::from_raw(width, height, pixels) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid SVG image dimensions")) +} + #[cfg(feature = "turbo")] fn is_jpeg(path: &Path) -> bool { path.extension() @@ -1180,6 +1385,11 @@ fn decode_jpeg_turbo(path: &Path, target: Option<(u32, u32)>) -> io::Result Option { + // SVGs may reference other files or system fonts not covered by this key. + if is_svg(path) { + return None; + } + use std::collections::hash_map::DefaultHasher; use std::fs; use std::hash::{Hash, Hasher}; @@ -1309,9 +1519,360 @@ mod tests { use super::{ decode_image_with_hint, get_cache_file_path, resize_decoded_to_dims, resize_to_exact, }; - use image::DynamicImage; + use image::{DynamicImage, GenericImageView}; use std::fs; + #[test] + fn svg_renders_viewbox_at_target_size_with_straight_alpha() { + let path = + std::env::temp_dir().join(format!("rview-synthetic-alpha-{}.SVG", std::process::id())); + fs::write( + &path, + r##" + + "##, + ) + .unwrap(); + + let thumbnail = decode_image_with_hint(&path, Some((20, 20))) + .unwrap() + .into_rgba8(); + let source = decode_image_with_hint(&path, None).unwrap(); + let larger = decode_image_with_hint(&path, Some((200, 200))).unwrap(); + let zero_target = decode_image_with_hint(&path, Some((0, 20))).unwrap(); + fs::remove_file(path).unwrap(); + + assert_eq!(thumbnail.dimensions(), (20, 10)); + assert_eq!(thumbnail.get_pixel(5, 5).0, [255, 0, 0, 128]); + assert_eq!(thumbnail.get_pixel(15, 5).0, [0, 0, 0, 0]); + assert_eq!(source.dimensions(), (100, 50)); + assert_eq!(larger.dimensions(), (100, 50)); + assert_eq!(zero_target.dimensions(), (100, 50)); + } + + #[test] + fn svg_resolves_images_relative_to_its_directory() { + let root = std::env::temp_dir().join(format!( + "rview-synthetic-svg-resources-{}", + std::process::id() + )); + fs::create_dir_all(&root).unwrap(); + image::RgbaImage::from_pixel(10, 10, image::Rgba([0, 255, 0, 255])) + .save(root.join("tile.png")) + .unwrap(); + let path = root.join("image.svg"); + fs::write( + &path, + r#" + + "#, + ) + .unwrap(); + let image = decode_image_with_hint(&path, None).unwrap().into_rgba8(); + fs::remove_dir_all(root).unwrap(); + assert_eq!(image.get_pixel(5, 5).0, [0, 255, 0, 255]); + } + + #[test] + fn svg_thumbnail_refreshes_when_a_referenced_image_changes() { + use super::App; + use crate::image_list::SharedImageList; + use crate::theme::Theme; + use ratatui::layout::Rect; + + let root = std::env::temp_dir().join(format!( + "rview-synthetic-svg-thumbnail-{}", + std::process::id() + )); + fs::create_dir_all(&root).unwrap(); + let resource = root.join("tile.png"); + let source = root.join("image.SVG"); + fs::write( + &source, + r#" + + "#, + ) + .unwrap(); + let mut pixels = Vec::new(); + for color in [[0, 255, 0, 255], [0, 0, 255, 255]] { + image::RgbaImage::from_pixel(10, 10, image::Rgba(color)) + .save(&resource) + .unwrap(); + // A new viewer must not reuse a thumbnail of the old reference. + let mut app = App::new(Theme::fallback(), (1, 1), SharedImageList::new()); + app.spawn_thumb_decode(0, source.clone(), Rect::new(0, 0, 10, 10)); + let (_, _, image) = app + .thumb_rx + .recv_timeout(std::time::Duration::from_secs(10)) + .unwrap(); + pixels.push(image.get_pixel(5, 5).0); + } + if let Some(cache) = get_cache_file_path(&source, 10, 10) { + let _ = fs::remove_file(cache); + } + fs::remove_dir_all(root).unwrap(); + assert_eq!(pixels, [[0, 255, 0, 255], [0, 0, 255, 255]]); + } + + #[test] + fn oversized_svg_rejects_intrinsic_decode_but_allows_bounded_rendering() { + let path = std::env::temp_dir().join(format!( + "rview-synthetic-oversized-{}.svg", + std::process::id() + )); + fs::write( + &path, + r#" + + "#, + ) + .unwrap(); + let intrinsic = decode_image_with_hint(&path, None).unwrap_err(); + let oversized_target = + decode_image_with_hint(&path, Some((100000000, 100000000))).unwrap_err(); + let bounded = decode_image_with_hint(&path, Some((20, 20))) + .unwrap() + .into_rgba8(); + fs::remove_file(path).unwrap(); + assert_eq!(intrinsic.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(oversized_target.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(bounded.dimensions(), (20, 20)); + assert_eq!(bounded.get_pixel(10, 10).0, [255, 0, 0, 255]); + } + + #[test] + fn svg_zoom_preserves_filter_inputs_outside_the_viewport() { + use super::{App, ZoomSource}; + use crate::image_list::SharedImageList; + use crate::theme::Theme; + use ratatui::layout::Rect; + + let tree = resvg::usvg::Tree::from_str( + r#" + + + + + "#, + &resvg::usvg::Options::default(), + ) + .unwrap(); + let mut app = App::new(Theme::fallback(), (1, 1), SharedImageList::new()); + app.reset_zoom_state(); + app.image_rect = Rect::new(0, 0, 100, 100); + app.source = Some(ZoomSource::Svg(tree)); + app.zoom = 16.0; + let view = app.build_zoom_view().unwrap(); + assert_eq!(view.get_pixel(75, 50).0, [255, 0, 0, 255]); + assert_eq!(view.get_pixel(25, 50).0, [0, 0, 0, 0]); + app.pan_x = (46.875 - 0.5 / 16.0) / 93.75; + let shifted = app.build_zoom_view().unwrap(); + assert!(shifted.get_pixel(50, 50).0[3] > 0); + assert!(shifted.get_pixel(50, 50).0[3] < 255); + assert_eq!(shifted.get_pixel(51, 50).0, [255, 0, 0, 255]); + } + + #[test] + fn svg_zoom_preserves_detail_smaller_than_an_intrinsic_pixel() { + use super::App; + use crate::image_list::SharedImageList; + use crate::theme::Theme; + use ratatui::layout::Rect; + use std::fmt::Write; + use std::time::{Duration, Instant}; + + let path = std::env::temp_dir().join(format!( + "rview-synthetic-svg-zoom-detail-{}.svg", + std::process::id() + )); + let mut svg = String::from( + r#" + "#, + ); + for stripe in 0..256 { + write!( + svg, + r#""#, + f64::from(stripe) * 0.25 + ) + .unwrap(); + } + svg.push_str(""); + fs::write(&path, svg).unwrap(); + let mut app = App::new(Theme::fallback(), (1, 1), SharedImageList::new()); + app.reset_zoom_state(); + app.images.push(path.clone()); + app.image_rect = Rect::new(0, 0, 64, 64); + app.zoom = 8.0; + app.start_source_decode(0); + let deadline = Instant::now() + Duration::from_secs(10); + while !app.poll_source() { + assert!(Instant::now() < deadline, "SVG source decode timed out"); + std::thread::sleep(Duration::from_millis(1)); + } + app.load_if_needed(); + let view = app.loaded.as_ref().unwrap(); + fs::remove_file(path).unwrap(); + + for x in 0..64 { + let value = if x % 2 == 0 { 0 } else { 255 }; + assert_eq!(view.get_pixel(x, 32).0, [value, value, value, 255]); + } + } + + #[test] + fn svg_zoom_pans_fractional_source_extents_with_straight_alpha() { + use super::{App, ZoomSource, parse_svg}; + use crate::image_list::SharedImageList; + use crate::theme::Theme; + use ratatui::layout::Rect; + + let path = std::env::temp_dir().join(format!( + "rview-synthetic-svg-fractional-pan-{}.svg", + std::process::id() + )); + fs::write( + &path, + r#" + + + + + "#, + ) + .unwrap(); + let mut app = App::new(Theme::fallback(), (1, 1), SharedImageList::new()); + app.reset_zoom_state(); + app.source = Some(ZoomSource::Svg(parse_svg(&path).unwrap())); + fs::remove_file(path).unwrap(); + app.image_rect = Rect::new(0, 0, 8, 8); + app.zoom = 16.0; + let view = app.build_zoom_view().unwrap(); + assert_eq!(view.get_pixel(3, 4).0, [255, 0, 0, 128]); + assert_eq!(view.get_pixel(4, 4).0, [0, 0, 255, 128]); + app.pan_x = 5.0 / 12.0; + let dragged = app.build_zoom_view().unwrap(); + assert_eq!(dragged.get_pixel(5, 4).0, [255, 0, 0, 128]); + assert_eq!(dragged.get_pixel(6, 4).0, [0, 0, 255, 128]); + app.pan(200.0, 0.0); + assert_eq!( + app.build_zoom_view().unwrap().get_pixel(0, 4).0, + [0, 0, 255, 128] + ); + app.pan(-200.0, 0.0); + assert_eq!( + app.build_zoom_view().unwrap().get_pixel(7, 4).0, + [255, 0, 0, 128] + ); + } + + #[test] + fn svg_filter_zoom_reports_canvas_limit_and_recovers() { + use super::{App, ZoomSource}; + use crate::image_list::SharedImageList; + use crate::theme::Theme; + use ratatui::layout::Rect; + + let tree = resvg::usvg::Tree::from_str( + r#" + + + + + "#, + &resvg::usvg::Options::default(), + ) + .unwrap(); + let mut app = App::new(Theme::fallback(), (1, 1), SharedImageList::new()); + app.reset_zoom_state(); + app.image_rect = Rect::new(0, 0, 1000, 1000); + app.source = Some(ZoomSource::Svg(tree)); + app.source_for = Some(0); + app.zoom = 16.0; + app.zoom_dirty = true; + app.load_if_needed(); + assert!(app.loaded.is_none()); + assert!(app.error.is_some()); + + app.zoom = 1.25; + app.zoom_dirty = true; + app.load_if_needed(); + let view = app.loaded.as_ref().unwrap(); + assert_eq!(view.dimensions(), (1000, 1000)); + assert_eq!(view.get_pixel(500, 500).0, [255, 0, 0, 255]); + assert!(app.error.is_none()); + } + + #[test] + fn svg_zoom_allocates_only_the_visible_viewport() { + use super::{App, ZoomSource, parse_svg}; + use crate::image_list::SharedImageList; + use crate::theme::Theme; + use ratatui::layout::Rect; + + let path = std::env::temp_dir().join(format!( + "rview-synthetic-svg-bounded-zoom-{}.svg", + std::process::id() + )); + fs::write( + &path, + r#" + + "#, + ) + .unwrap(); + let mut app = App::new(Theme::fallback(), (1, 1), SharedImageList::new()); + app.reset_zoom_state(); + app.source = Some(ZoomSource::Svg(parse_svg(&path).unwrap())); + app.source_for = Some(0); + fs::remove_file(path).unwrap(); + app.image_rect = Rect::new(0, 0, 20, 20); + app.zoom = 8.0; + app.load_if_needed(); + let view = app.loaded.as_ref().unwrap(); + assert_eq!(view.dimensions(), (20, 20)); + assert_eq!(view.get_pixel(10, 10).0, [255, 0, 0, 255]); + + // A large output, unlike a large intrinsic size, must still respect the budget. + app.cell_px = (1000000, 1000000); + assert_eq!( + app.build_zoom_view().unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + app.zoom_dirty = true; + app.load_if_needed(); + assert!(app.loaded.is_none()); + assert!(app.error.is_some()); + app.cell_px = (1, 1); + app.zoom_dirty = true; + app.load_if_needed(); + assert_eq!( + app.loaded.as_ref().unwrap().get_pixel(10, 10).0, + [255, 0, 0, 255] + ); + assert!(app.error.is_none()); + } + + #[test] + fn malformed_svg_returns_invalid_data() { + let path = std::env::temp_dir().join(format!( + "rview-synthetic-malformed-{}.svg", + std::process::id() + )); + fs::write(&path, " fit scale 0.25 (base image is 100x100). - app.source = Some(DynamicImage::new_rgba8(400, 400)); + app.source = Some(super::ZoomSource::Raster(DynamicImage::new_rgba8(400, 400))); app.source_for = Some(0); app.current = 0; diff --git a/src/scanner.rs b/src/scanner.rs index 18026f2..cdd0962 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -5,7 +5,7 @@ use std::thread; const BATCH_SIZE: usize = 64; const IMAGE_EXTENSIONS: &[&str] = &[ - "png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif", "ico", "avif", + "png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif", "ico", "avif", "svg", ]; #[cfg(feature = "video")] @@ -118,6 +118,8 @@ mod tests { fn supported_extensions_are_case_insensitive() { assert!(is_supported(Path::new("synthetic.PNG"))); assert!(is_supported(Path::new("synthetic.jpeg"))); + assert!(is_supported(Path::new("synthetic.svg"))); + assert!(is_supported(Path::new("synthetic.SVG"))); } #[test]