Skip to main content

lattice_media/
lib.rs

1//! Inline media: resolving an image block's pixels off the UI thread, so the
2//! renderer only ever paints decoded pixels or a placeholder (IM.4).
3//!
4//! Design: `docs/dev/architecture/inline-media.md` §5.
5//!
6//! ## The rule this code exists to serve
7//!
8//! **Decode never happens on the UI thread.** `gpui::img()` will happily take
9//! a path and load it, which means file I/O and PNG decode inside the render
10//! pass — precisely the forbidden pattern (paramount goal #1). So the peer
11//! never gets a path to draw; it gets pixels that were produced elsewhere, or
12//! it gets nothing and paints the placeholder.
13//!
14//! That guarantee lives at the CALL SITE, not here. [`MediaCache::get`] is
15//! async and goes through `spawn_blocking`, which is the ergonomic path; but
16//! [`decode`] is public and synchronous, and nothing in this crate's
17//! dependency graph prevents a renderer calling it from inside a paint. The
18//! rule is enforced by the caller and by the tests that assert frame time is
19//! unaffected — stating otherwise would be a false comfort.
20//!
21//! ## Why a crate, and why not inside the GPUI peer
22//!
23//! Only GPUI draws images today. This is not in `lattice-ui-gpui` because
24//! terminal graphics (kitty / sixel / iTerm2) is deliberately not foreclosed —
25//! see `docs/dev/architecture/inline-media.md` §9 — and the day the TUI grows
26//! an image path, both peers need this code. A thing both peers need can live
27//! in neither of them.
28//!
29//! It is not in `lattice-cells` either: that crate has one dependency and ten
30//! dependents, and none of `lattice-completion`, `lattice-listing` or
31//! `lattice-diff` should be compiling PNG decoders.
32//!
33//! ## Size before pixels
34//!
35//! [`probe`] reads only enough of the file to learn its dimensions;
36//! [`decode`] reads and decodes the whole thing. They are separate because a
37//! block must be able to reserve the right amount of space **before** its
38//! pixels exist — otherwise the document reflows when an image finishes
39//! loading, which breaks the keystroke contract ("no pixel change to content
40//! the user did not edit") on every scroll past it.
41//!
42//! A header read is bounded and cheap; it still happens off-thread, because
43//! "cheap" and "on the UI thread" are different claims.
44//!
45//! ## The cache
46//!
47//! Keyed by `(path, mtime, target)`. `mtime` is what makes an edited image
48//! reappear rather than serving a stale decode forever, and `target` is in the
49//! key because the same file at two display scales is two different decodes.
50
51use std::collections::HashMap;
52use std::path::{Path, PathBuf};
53use std::sync::{Arc, Mutex};
54
55/// The byte layout a consumer needs.
56///
57/// A parameter rather than a fixed output, and that is not speculative
58/// generality — GPUI's `RenderImage` is **premultiplied BGRA** while a
59/// terminal graphics protocol wants straight RGBA. Converting at the
60/// consumer would put a per-pixel loop in the paint path, which is the exact
61/// thing this crate exists to keep out of it, so the swap happens inside the
62/// same off-thread decode and is cached in that form.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
64pub enum PixelFormat {
65    /// Straight RGBA8. What image files decode to, and what a kitty / sixel
66    /// path would want.
67    #[default]
68    Rgba8,
69    /// Premultiplied BGRA8 — `gpui::RenderImage`'s required layout.
70    BgraPremultiplied8,
71}
72
73/// Decoded, scaled pixels ready for a peer to upload.
74#[derive(Clone, PartialEq, Eq)]
75pub struct DecodedImage {
76    pub width: u32,
77    pub height: u32,
78    pub format: PixelFormat,
79    /// Row-major, `width * height * 4` bytes, in [`Self::format`].
80    pub rgba: Arc<[u8]>,
81}
82
83impl std::fmt::Debug for DecodedImage {
84    /// Hand-written so a failing assertion prints dimensions instead of
85    /// several megabytes of pixels.
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("DecodedImage")
88            .field("width", &self.width)
89            .field("height", &self.height)
90            .field("bytes", &self.rgba.len())
91            .finish()
92    }
93}
94
95/// Why a block has no pixels. Every variant is a *placeholder plus alt text*
96/// outcome, never a panic and never a stall.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum MediaError {
99    /// The file is not there, or is not readable.
100    Unreadable(String),
101    /// Read fine, but no decoder recognised it.
102    Undecodable(String),
103    /// Bigger than [`MAX_PIXELS`]. Refused before allocating, so a
104    /// pathological or hostile image cannot exhaust memory.
105    TooLarge { width: u32, height: u32 },
106}
107
108impl std::fmt::Display for MediaError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            Self::Unreadable(e) => write!(f, "unreadable: {e}"),
112            Self::Undecodable(e) => write!(f, "undecodable: {e}"),
113            Self::TooLarge { width, height } => {
114                write!(f, "too large: {width}x{height}")
115            }
116        }
117    }
118}
119
120/// Refuse anything above ~64 megapixels.
121///
122/// Not a performance tuning knob — a decoded 64MP image is a quarter of a
123/// gigabyte of RGBA, and the dimensions come from a file header that a
124/// document can reference without the user having looked at it. Checking
125/// before allocating turns "the editor died opening a note" into "that image
126/// shows its alt text".
127pub const MAX_PIXELS: u64 = 64 * 1024 * 1024;
128
129/// True when `path` is an SVG, which is a document rather than a raster and
130/// takes an entirely different route through this crate.
131fn is_svg(path: &Path) -> bool {
132    path.extension()
133        .and_then(|e| e.to_str())
134        .is_some_and(|e| e.eq_ignore_ascii_case("svg"))
135}
136
137/// System fonts, loaded at most once.
138///
139/// SVG text is converted to paths at PARSE time, so a tree built without
140/// fonts silently drops every `<text>` element — a diagram renders with its
141/// boxes and none of its labels, which looks like a rendering bug rather than
142/// a missing font. Loading is slow enough (tens to hundreds of milliseconds,
143/// walking the system font directories) that it must happen once, and it is
144/// only ever reached from `decode`, which the callers run on a blocking
145/// thread.
146///
147/// `probe` deliberately does NOT take this path: a document's size comes from
148/// its root element, so measuring needs no fonts and must not pay for them.
149fn system_fonts() -> std::sync::Arc<resvg::usvg::fontdb::Database> {
150    static FONTS: std::sync::OnceLock<std::sync::Arc<resvg::usvg::fontdb::Database>> =
151        std::sync::OnceLock::new();
152    FONTS
153        .get_or_init(|| {
154            let mut db = resvg::usvg::fontdb::Database::new();
155            db.load_system_fonts();
156            std::sync::Arc::new(db)
157        })
158        .clone()
159}
160
161/// Parse an SVG into a `usvg` tree.
162///
163/// `with_fonts` decides whether `<text>` survives — see [`system_fonts`].
164/// `resources_dir` is the file's own directory, so an SVG that references a
165/// bitmap or a stylesheet beside it resolves relative to ITSELF rather than
166/// to wherever the editor was launched — the same rule the host applies to an
167/// org link, for the same reason.
168fn svg_tree(path: &Path, with_fonts: bool) -> Result<resvg::usvg::Tree, MediaError> {
169    let data = std::fs::read(path).map_err(|e| MediaError::Unreadable(e.to_string()))?;
170    let mut opt = resvg::usvg::Options {
171        resources_dir: path.parent().map(std::path::Path::to_path_buf),
172        ..Default::default()
173    };
174    if with_fonts {
175        opt.fontdb = system_fonts();
176    }
177    resvg::usvg::Tree::from_data(&data, &opt).map_err(|e| MediaError::Undecodable(e.to_string()))
178}
179
180/// An SVG's natural size, rounded UP to whole pixels.
181///
182/// Up, not nearest: a 24.2-pixel-wide drawing needs 25 pixels to hold it, and
183/// rounding down would clip a column.
184fn svg_size(tree: &resvg::usvg::Tree) -> (u32, u32) {
185    let size = tree.size();
186    (
187        size.width().ceil().max(1.0) as u32,
188        size.height().ceil().max(1.0) as u32,
189    )
190}
191
192/// A file's natural size, from its header alone.
193pub fn probe(path: &Path) -> Result<(u32, u32), MediaError> {
194    if is_svg(path) {
195        let (w, h) = svg_size(&svg_tree(path, /* with_fonts */ false)?);
196        guard_size(w, h)?;
197        return Ok((w, h));
198    }
199    let reader = image::ImageReader::open(path)
200        .map_err(|e| MediaError::Unreadable(e.to_string()))?
201        .with_guessed_format()
202        .map_err(|e| MediaError::Unreadable(e.to_string()))?;
203    let (w, h) = reader
204        .into_dimensions()
205        .map_err(|e| MediaError::Undecodable(e.to_string()))?;
206    guard_size(w, h)?;
207    Ok((w, h))
208}
209
210/// Decode `path` and scale it to fit `target` (width, height in px),
211/// preserving aspect ratio and never scaling up.
212///
213/// Never upscales: a 32×32 icon blown up to fill a block is worse than the
214/// same icon shown small, and the caller cannot tell the difference from the
215/// returned dimensions alone.
216pub fn decode(
217    path: &Path,
218    target: (u32, u32),
219    format: PixelFormat,
220) -> Result<DecodedImage, MediaError> {
221    if is_svg(path) {
222        return decode_svg(path, target, format);
223    }
224    let (w, h) = probe(path)?;
225    let img = image::ImageReader::open(path)
226        .map_err(|e| MediaError::Unreadable(e.to_string()))?
227        .with_guessed_format()
228        .map_err(|e| MediaError::Unreadable(e.to_string()))?
229        .decode()
230        .map_err(|e| MediaError::Undecodable(e.to_string()))?;
231
232    let (tw, th) = fit_within((w, h), target);
233    let scaled = if (tw, th) == (w, h) {
234        img
235    } else {
236        img.resize(tw, th, image::imageops::FilterType::Triangle)
237    };
238    let rgba = scaled.to_rgba8();
239    let (w, h) = (rgba.width(), rgba.height());
240    let mut bytes = rgba.into_raw();
241    if format == PixelFormat::BgraPremultiplied8 {
242        premultiply_to_bgra(&mut bytes);
243    }
244    Ok(DecodedImage {
245        width: w,
246        height: h,
247        format,
248        rgba: Arc::from(bytes.into_boxed_slice()),
249    })
250}
251
252/// Rasterise an SVG straight to the size it will be drawn at.
253///
254/// The one place in this crate where scaling is not a loss. A raster is
255/// decoded at its natural size and resampled down; a vector is *rendered* at
256/// the target, so a diagram shown at 300px is as crisp as the same diagram
257/// shown at 3000. That is also why the render transform is built from the
258/// fitted size rather than the pixmap being resized afterwards.
259///
260/// [`fit_within`] still applies, so the never-upscale rule holds: a 24×24
261/// icon stays 24×24 rather than being blown across the pane. Faithful, and
262/// consistent with what the raster path does with the same picture.
263fn decode_svg(
264    path: &Path,
265    target: (u32, u32),
266    format: PixelFormat,
267) -> Result<DecodedImage, MediaError> {
268    let tree = svg_tree(path, /* with_fonts */ true)?;
269    let natural = svg_size(&tree);
270    guard_size(natural.0, natural.1)?;
271    let (tw, th) = fit_within(natural, target);
272    let mut pixmap = resvg::tiny_skia::Pixmap::new(tw.max(1), th.max(1))
273        .ok_or_else(|| MediaError::Undecodable(format!("cannot allocate a {tw}x{th} pixmap")))?;
274    let size = tree.size();
275    let transform = resvg::tiny_skia::Transform::from_scale(
276        tw as f32 / size.width(),
277        th as f32 / size.height(),
278    );
279    resvg::render(&tree, transform, &mut pixmap.as_mut());
280
281    // tiny-skia hands back PREMULTIPLIED RGBA, which is not what either of
282    // our formats is by default. The raster path premultiplies on the way to
283    // BGRA; this one is already premultiplied and only needs the channel
284    // swap — running `premultiply_to_bgra` here would multiply by alpha a
285    // second time and darken every soft edge.
286    let (w, h) = (pixmap.width(), pixmap.height());
287    let mut bytes = pixmap.take();
288    match format {
289        PixelFormat::BgraPremultiplied8 => swap_rb(&mut bytes),
290        PixelFormat::Rgba8 => unpremultiply(&mut bytes),
291    }
292    Ok(DecodedImage {
293        width: w,
294        height: h,
295        format,
296        rgba: Arc::from(bytes.into_boxed_slice()),
297    })
298}
299
300/// In-place channel swap: premultiplied RGBA8 → premultiplied BGRA8.
301///
302/// No arithmetic — the alpha has already been applied by the rasteriser.
303fn swap_rb(bytes: &mut [u8]) {
304    for px in bytes.chunks_exact_mut(4) {
305        px.swap(0, 2);
306    }
307}
308
309/// In-place premultiplied RGBA8 → straight RGBA8.
310///
311/// The inverse of what [`premultiply_to_bgra`] does to the colour channels,
312/// for the peers that want straight alpha (a terminal graphics protocol).
313/// `a == 0` keeps the pixel black rather than dividing by zero: a fully
314/// transparent pixel has no colour to recover.
315fn unpremultiply(bytes: &mut [u8]) {
316    for px in bytes.chunks_exact_mut(4) {
317        let a = px[3];
318        if a == 0 || a == 255 {
319            continue;
320        }
321        for c in &mut px[..3] {
322            *c = ((*c as u16 * 255 + (a as u16 / 2)) / a as u16).min(255) as u8;
323        }
324    }
325}
326
327/// In-place RGBA8 → premultiplied BGRA8.
328///
329/// Runs inside the off-thread decode, never at paint. Premultiplication is
330/// what stops a transparent PNG showing a dark halo where the compositor
331/// blends un-premultiplied colour against the background.
332fn premultiply_to_bgra(bytes: &mut [u8]) {
333    for px in bytes.chunks_exact_mut(4) {
334        let (r, g, b, a) = (px[0], px[1], px[2], px[3]);
335        // `+ 127) / 255` rounds instead of truncating; truncation darkens
336        // every semi-transparent pixel by up to one level, which is visible
337        // as a dingy edge on antialiased artwork.
338        let mul = |c: u8| (((c as u16) * (a as u16) + 127) / 255) as u8;
339        px[0] = mul(b);
340        px[1] = mul(g);
341        px[2] = mul(r);
342        px[3] = a;
343    }
344}
345
346fn guard_size(w: u32, h: u32) -> Result<(), MediaError> {
347    if w == 0 || h == 0 {
348        return Err(MediaError::Undecodable("zero-sized image".into()));
349    }
350    if (w as u64) * (h as u64) > MAX_PIXELS {
351        return Err(MediaError::TooLarge {
352            width: w,
353            height: h,
354        });
355    }
356    Ok(())
357}
358
359/// Scale `src` down to fit inside `bounds`, preserving aspect ratio. Returns
360/// `src` unchanged when it already fits — the never-upscale rule.
361pub fn fit_within(src: (u32, u32), bounds: (u32, u32)) -> (u32, u32) {
362    let (sw, sh) = src;
363    let (bw, bh) = bounds;
364    if bw == 0 || bh == 0 || (sw <= bw && sh <= bh) {
365        return src;
366    }
367    let scale = (bw as f64 / sw as f64).min(bh as f64 / sh as f64);
368    // `max(1)`: a very wide, very short image must not scale to zero rows and
369    // vanish. One pixel is honest; zero is a disappearance.
370    (
371        ((sw as f64 * scale).round() as u32).max(1),
372        ((sh as f64 * scale).round() as u32).max(1),
373    )
374}
375
376/// How much space a block should take, given what it is and where it goes.
377///
378/// Returns `(rows, height_lh)`:
379///
380/// - `rows` — display rows to reserve. **Both peers agree on this**, which is
381///   what keeps `scroll` (a row index) anchoring the same source line
382///   whichever renderer is running.
383/// - `height_lh` — the drawn height in line-heights, which the drawing peer
384///   spends through `RowWeights`. Deliberately allowed to be fractional: a
385///   3.4-line-height image reserves 4 rows and draws 3.4 of them. Snapping it
386///   to 4 is what §3 of the design rejected, because it makes the rendered
387///   size a function of the font size.
388///
389/// `rows` is `ceil(height_lh)` so the reservation never under-covers the
390/// drawing — an image must not paint over the line beneath it.
391pub fn block_geometry(
392    intrinsic: (u32, u32),
393    fit: lattice_cells::MediaFit,
394    line_height_px: f32,
395    pane_width_px: f32,
396) -> (u16, f32) {
397    // Degenerate geometry (a pane not yet measured, a zero line height)
398    // yields one row rather than a division by zero or an absurd
399    // reservation. The next frame with real metrics resolves it properly.
400    // `is_finite` first: a NaN pane width would slip past a bare `<= 0.0`
401    // comparison and propagate into the row count.
402    if !line_height_px.is_finite()
403        || !pane_width_px.is_finite()
404        || line_height_px <= 0.0
405        || pane_width_px <= 0.0
406    {
407        return (1, 1.0);
408    }
409    let (iw, ih) = intrinsic;
410    if iw == 0 || ih == 0 {
411        return (1, 1.0);
412    }
413
414    let drawn_h_px = match fit {
415        // Never wider than the pane, and never scaled UP — a 32×32 icon
416        // stretched across the pane is worse than the icon.
417        lattice_cells::MediaFit::Contain => {
418            let scale = (pane_width_px / iw as f32).min(1.0);
419            ih as f32 * scale
420        }
421        // Always fill the width, up or down, and let the height follow.
422        lattice_cells::MediaFit::Width => ih as f32 * (pane_width_px / iw as f32),
423    };
424
425    let height_lh = (drawn_h_px / line_height_px).max(MIN_BLOCK_LH);
426    let rows = height_lh.ceil().min(u16::MAX as f32) as u16;
427    (rows.max(1), height_lh)
428}
429
430/// A block never draws thinner than this, however extreme its aspect ratio.
431///
432/// A 10000×1 rule would otherwise resolve to a fraction of a line-height and
433/// be invisible while still consuming a row — the "it silently did nothing"
434/// failure that `media_block_rows` clamps against on the row side.
435pub const MIN_BLOCK_LH: f32 = 1.0;
436
437/// What a cache entry is keyed on.
438///
439/// `mtime` is why an edited image reappears instead of serving a stale decode
440/// forever; `target` is in the key because the same file at two display scales
441/// is genuinely two decodes.
442#[derive(Debug, Clone, PartialEq, Eq, Hash)]
443struct CacheKey {
444    path: PathBuf,
445    mtime: Option<i64>,
446    target: (u32, u32),
447    format: PixelFormat,
448}
449
450/// A bounded decode cache.
451///
452/// Bounded by total decoded bytes rather than entry count: entries differ in
453/// size by orders of magnitude, so counting them bounds nothing that matters.
454/// Eviction is oldest-inserted-first, which is the right default for a
455/// document being read top to bottom.
456pub struct MediaCache {
457    inner: Mutex<CacheInner>,
458    budget_bytes: usize,
459}
460
461struct CacheInner {
462    entries: HashMap<CacheKey, Arc<DecodedImage>>,
463    order: Vec<CacheKey>,
464    bytes: usize,
465}
466
467impl MediaCache {
468    pub fn new(budget_bytes: usize) -> Self {
469        Self {
470            inner: Mutex::new(CacheInner {
471                entries: HashMap::new(),
472                order: Vec::new(),
473                bytes: 0,
474            }),
475            budget_bytes,
476        }
477    }
478
479    /// Decoded pixels for `path` at `target`, decoding on a blocking thread if
480    /// they are not cached.
481    ///
482    /// `spawn_blocking`, not `spawn`: the editor actor runs on a
483    /// `current_thread` runtime, so a plain `spawn` would land the decode on
484    /// the actor thread and stall exactly what this crate exists to protect.
485    pub async fn get(
486        self: &Arc<Self>,
487        path: &Path,
488        target: (u32, u32),
489        format: PixelFormat,
490    ) -> Result<Arc<DecodedImage>, MediaError> {
491        let key = CacheKey {
492            path: path.to_path_buf(),
493            mtime: mtime_of(path),
494            target,
495            format,
496        };
497        if let Some(hit) = self.lookup(&key) {
498            return Ok(hit);
499        }
500        let owned = path.to_path_buf();
501        let decoded = tokio::task::spawn_blocking(move || decode(&owned, target, format))
502            .await
503            .map_err(|e| MediaError::Unreadable(format!("decode task failed: {e}")))??;
504        let decoded = Arc::new(decoded);
505        self.insert(key, decoded.clone());
506        Ok(decoded)
507    }
508
509    fn lookup(&self, key: &CacheKey) -> Option<Arc<DecodedImage>> {
510        self.inner.lock().ok()?.entries.get(key).cloned()
511    }
512
513    fn insert(&self, key: CacheKey, value: Arc<DecodedImage>) {
514        let Ok(mut inner) = self.inner.lock() else {
515            // A poisoned cache mutex must not take the editor with it: skip
516            // the caching and let the next request decode again.
517            tracing::warn!("media cache mutex poisoned; skipping insert");
518            return;
519        };
520        let size = value.rgba.len();
521        if inner.entries.insert(key.clone(), value).is_none() {
522            inner.order.push(key);
523            inner.bytes += size;
524        }
525        while inner.bytes > self.budget_bytes && !inner.order.is_empty() {
526            let oldest = inner.order.remove(0);
527            if let Some(dropped) = inner.entries.remove(&oldest) {
528                inner.bytes = inner.bytes.saturating_sub(dropped.rgba.len());
529            }
530        }
531    }
532
533    /// Current cached byte total. Diagnostics and tests.
534    pub fn bytes(&self) -> usize {
535        self.inner.lock().map(|i| i.bytes).unwrap_or(0)
536    }
537
538    /// Drop everything — called when the last buffer referencing media closes.
539    pub fn clear(&self) {
540        if let Ok(mut inner) = self.inner.lock() {
541            inner.entries.clear();
542            inner.order.clear();
543            inner.bytes = 0;
544        }
545    }
546}
547
548fn mtime_of(path: &Path) -> Option<i64> {
549    let meta = std::fs::metadata(path).ok()?;
550    let modified = meta.modified().ok()?;
551    let dur = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
552    Some(dur.as_secs() as i64)
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    fn write_png(dir: &Path, name: &str, w: u32, h: u32) -> PathBuf {
560        let path = dir.join(name);
561        let buf = image::RgbaImage::from_pixel(w, h, image::Rgba([10, 20, 30, 255]));
562        buf.save(&path).expect("write png");
563        path
564    }
565
566    fn write_svg(dir: &Path, name: &str, body: &str) -> PathBuf {
567        let path = dir.join(name);
568        std::fs::write(&path, body).expect("write svg");
569        path
570    }
571
572    /// A 40×20 drawing, opaque red, no text — so it renders identically with
573    /// or without fonts.
574    const RED_40X20: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20">
575        <rect x="0" y="0" width="40" height="20" fill="#ff0000"/>
576    </svg>"##;
577
578    /// An SVG is measured from its root element — no rasterising, and no
579    /// fonts. Before this, `image` did not recognise the file at all and the
580    /// block fell back to its alt text.
581    #[test]
582    fn probe_reads_an_svgs_declared_size() {
583        let dir = tempfile::tempdir().unwrap();
584        let path = write_svg(dir.path(), "d.svg", RED_40X20);
585        assert_eq!(probe(&path).unwrap(), (40, 20));
586    }
587
588    /// A fractional size needs the whole pixel that holds it; rounding down
589    /// would clip a column.
590    #[test]
591    fn an_svgs_size_rounds_up_to_whole_pixels() {
592        let dir = tempfile::tempdir().unwrap();
593        let path = write_svg(
594            dir.path(),
595            "f.svg",
596            r#"<svg xmlns="http://www.w3.org/2000/svg" width="10.2" height="4.1"></svg>"#,
597        );
598        assert_eq!(probe(&path).unwrap(), (11, 5));
599    }
600
601    /// A vector is RENDERED at the target rather than resampled to it, but
602    /// the never-upscale rule still holds — so a small drawing in a large
603    /// box stays its own size.
604    #[test]
605    fn an_svg_renders_at_the_fitted_size_and_is_never_upscaled() {
606        let dir = tempfile::tempdir().unwrap();
607        let path = write_svg(dir.path(), "d.svg", RED_40X20);
608
609        let small = decode(&path, (20, 10), PixelFormat::Rgba8).unwrap();
610        assert_eq!((small.width, small.height), (20, 10), "scaled down to fit");
611
612        let huge = decode(&path, (4000, 4000), PixelFormat::Rgba8).unwrap();
613        assert_eq!(
614            (huge.width, huge.height),
615            (40, 20),
616            "never upscaled, exactly as a raster of the same size is not"
617        );
618    }
619
620    /// The colour has to survive the trip. tiny-skia returns PREMULTIPLIED
621    /// RGBA, so running the raster path's `premultiply_to_bgra` over it would
622    /// apply alpha twice; `Rgba8` has to undo the premultiplication instead.
623    /// Both directions are checked against a known pixel, because either
624    /// mistake shows up as "slightly wrong colours" rather than as a failure.
625    #[test]
626    fn an_opaque_svg_pixel_survives_both_pixel_formats() {
627        let dir = tempfile::tempdir().unwrap();
628        let path = write_svg(dir.path(), "d.svg", RED_40X20);
629
630        let rgba = decode(&path, (40, 20), PixelFormat::Rgba8).unwrap();
631        assert_eq!(
632            &rgba.rgba[..4],
633            &[255, 0, 0, 255],
634            "straight RGBA: opaque red stays opaque red"
635        );
636
637        let bgra = decode(&path, (40, 20), PixelFormat::BgraPremultiplied8).unwrap();
638        assert_eq!(
639            &bgra.rgba[..4],
640            &[0, 0, 255, 255],
641            "premultiplied BGRA: the channels swap and nothing is multiplied twice"
642        );
643    }
644
645    /// A half-transparent pixel is the case that exposes a double
646    /// premultiply: at alpha 128, red premultiplied once is ~128 and twice is
647    /// ~64.
648    #[test]
649    fn a_semi_transparent_svg_pixel_is_premultiplied_exactly_once() {
650        let dir = tempfile::tempdir().unwrap();
651        let path = write_svg(
652            dir.path(),
653            "t.svg",
654            r##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4">
655                <rect x="0" y="0" width="4" height="4" fill="#ff0000" fill-opacity="0.5"/>
656            </svg>"##,
657        );
658
659        let bgra = decode(&path, (4, 4), PixelFormat::BgraPremultiplied8).unwrap();
660        let (b, g, r, a) = (bgra.rgba[0], bgra.rgba[1], bgra.rgba[2], bgra.rgba[3]);
661        assert_eq!((b, g), (0, 0));
662        assert!((120..=136).contains(&a), "half transparent, got alpha {a}");
663        assert!(
664            r.abs_diff(a) <= 2,
665            "red premultiplied ONCE is ~alpha ({a}); got {r}.              Twice would be ~{}",
666            (a as u16 * a as u16 / 255)
667        );
668    }
669
670    /// Malformed SVG is a typed error and the alt text stands in — never a
671    /// panic, exactly like a truncated PNG.
672    #[test]
673    fn a_malformed_svg_is_an_error_not_a_panic() {
674        let dir = tempfile::tempdir().unwrap();
675        let path = write_svg(dir.path(), "bad.svg", "<svg this is not xml");
676        assert!(matches!(probe(&path), Err(MediaError::Undecodable(_))));
677        assert!(decode(&path, (10, 10), PixelFormat::Rgba8).is_err());
678    }
679
680    #[test]
681    fn probe_reads_dimensions_without_decoding() {
682        let dir = tempfile::tempdir().unwrap();
683        let p = write_png(dir.path(), "a.png", 40, 25);
684        assert_eq!(probe(&p).unwrap(), (40, 25));
685    }
686
687    /// Every failure is a placeholder outcome, never a panic. A document can
688    /// reference a file the user has never looked at.
689    #[test]
690    fn every_failure_mode_is_an_error_not_a_panic() {
691        let dir = tempfile::tempdir().unwrap();
692        assert!(matches!(
693            probe(&dir.path().join("nope.png")),
694            Err(MediaError::Unreadable(_))
695        ));
696
697        let junk = dir.path().join("junk.png");
698        std::fs::write(&junk, b"this is not a png").unwrap();
699        assert!(probe(&junk).is_err(), "garbage is refused, not decoded");
700    }
701
702    #[test]
703    fn fit_never_upscales_and_never_vanishes() {
704        // Already fits ⇒ untouched.
705        assert_eq!(fit_within((40, 25), (100, 100)), (40, 25));
706        // Scales down preserving ratio.
707        assert_eq!(fit_within((200, 100), (100, 100)), (100, 50));
708        assert_eq!(fit_within((100, 200), (100, 100)), (50, 100));
709        // A very wide, very short image must not scale to zero rows and
710        // disappear — one pixel is honest, zero is a vanishing.
711        let (w, h) = fit_within((10_000, 3), (100, 100));
712        assert!(h >= 1 && w >= 1, "got {w}x{h}");
713    }
714
715    /// The dimensions come from a file header a document can reference
716    /// without the user ever looking at it, so the refusal happens BEFORE the
717    /// allocation.
718    #[test]
719    fn an_absurd_size_is_refused_before_allocating() {
720        assert!(matches!(
721            guard_size(100_000, 100_000),
722            Err(MediaError::TooLarge { .. })
723        ));
724        assert!(guard_size(0, 10).is_err(), "zero-sized is not an image");
725        assert!(guard_size(1920, 1080).is_ok());
726    }
727
728    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
729    async fn decoding_scales_to_fit_and_caches_the_result() {
730        let dir = tempfile::tempdir().unwrap();
731        let p = write_png(dir.path(), "big.png", 200, 100);
732        let cache = Arc::new(MediaCache::new(8 * 1024 * 1024));
733
734        let first = cache
735            .get(&p, (100, 100), PixelFormat::Rgba8)
736            .await
737            .expect("decodes");
738        assert_eq!((first.width, first.height), (100, 50), "scaled to fit");
739        assert_eq!(first.rgba.len(), 100 * 50 * 4, "RGBA8");
740
741        let again = cache
742            .get(&p, (100, 100), PixelFormat::Rgba8)
743            .await
744            .expect("decodes");
745        assert!(Arc::ptr_eq(&first, &again), "second get is a cache hit");
746
747        // A different target is a different decode, not a stale hit.
748        let other = cache
749            .get(&p, (40, 40), PixelFormat::Rgba8)
750            .await
751            .expect("decodes");
752        assert_eq!((other.width, other.height), (40, 20));
753    }
754
755    /// The cache is bounded by BYTES, not entries: decoded images differ in
756    /// size by orders of magnitude, so an entry count bounds nothing.
757    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
758    async fn the_cache_evicts_to_stay_within_its_byte_budget() {
759        let dir = tempfile::tempdir().unwrap();
760        // 100x100 RGBA = 40_000 bytes each; budget holds one.
761        let cache = Arc::new(MediaCache::new(50_000));
762        for i in 0..4 {
763            let p = write_png(dir.path(), &format!("{i}.png"), 100, 100);
764            cache
765                .get(&p, (100, 100), PixelFormat::Rgba8)
766                .await
767                .expect("decodes");
768            assert!(
769                cache.bytes() <= 50_000,
770                "over budget after {i}: {}",
771                cache.bytes()
772            );
773        }
774        cache.clear();
775        assert_eq!(cache.bytes(), 0);
776    }
777
778    #[test]
779    fn geometry_scales_to_the_pane_and_reserves_whole_rows() {
780        use lattice_cells::MediaFit;
781        // 400x200 in a 200px pane at 20px lines: halved to 200x100, which is
782        // 5 line-heights, so 5 rows.
783        let (rows, lh) = block_geometry((400, 200), MediaFit::Contain, 20.0, 200.0);
784        assert_eq!(rows, 5);
785        assert!((lh - 5.0).abs() < 0.001, "got {lh}");
786
787        // Fractional heights are KEPT, not snapped — that is what makes the
788        // block variable-height rather than whole-row. 4.25 draws 4.25 and
789        // reserves 5, so it never paints over the line beneath it.
790        let (rows, lh) = block_geometry((400, 170), MediaFit::Contain, 20.0, 200.0);
791        assert!((lh - 4.25).abs() < 0.001, "got {lh}");
792        assert_eq!(
793            rows, 5,
794            "rows is ceil(height), so the reservation covers it"
795        );
796    }
797
798    /// `Contain` never scales up; `Width` does. A small icon blown up to fill
799    /// the pane is worse than the icon.
800    #[test]
801    fn contain_never_upscales_but_width_does() {
802        use lattice_cells::MediaFit;
803        let (_, lh_contain) = block_geometry((32, 32), MediaFit::Contain, 20.0, 400.0);
804        assert!(
805            (lh_contain - 1.6).abs() < 0.001,
806            "32px / 20px lines, got {lh_contain}"
807        );
808
809        let (_, lh_width) = block_geometry((32, 32), MediaFit::Width, 20.0, 400.0);
810        assert!(
811            (lh_width - 20.0).abs() < 0.001,
812            "filled the width, got {lh_width}"
813        );
814    }
815
816    /// Degenerate geometry must not divide by zero or reserve something
817    /// absurd — an unmeasured pane resolves on the next frame.
818    #[test]
819    fn degenerate_geometry_yields_one_row_rather_than_nonsense() {
820        use lattice_cells::MediaFit;
821        for args in [
822            ((100, 100), 0.0, 200.0),
823            ((100, 100), 20.0, 0.0),
824            ((0, 100), 20.0, 200.0),
825            ((100, 0), 20.0, 200.0),
826        ] {
827            let (rows, lh) = block_geometry(args.0, MediaFit::Contain, args.1, args.2);
828            assert_eq!((rows, lh), (1, 1.0), "for {args:?}");
829        }
830    }
831
832    /// An extreme aspect ratio must not resolve to a sliver that is invisible
833    /// while still occupying a row.
834    #[test]
835    fn an_extreme_ratio_still_draws_at_least_one_line() {
836        use lattice_cells::MediaFit;
837        let (rows, lh) = block_geometry((10_000, 1), MediaFit::Contain, 20.0, 200.0);
838        assert!(lh >= MIN_BLOCK_LH, "got {lh}");
839        assert_eq!(rows, 1);
840    }
841
842    /// GPUI needs premultiplied BGRA. Doing the swap at the consumer would
843    /// put a per-pixel loop in the paint path — the exact thing this crate
844    /// exists to keep out of it — so it happens inside the off-thread decode
845    /// and is cached in that form.
846    #[test]
847    fn bgra_premultiplication_happens_at_decode_not_at_paint() {
848        let dir = tempfile::tempdir().unwrap();
849        let path = dir.path().join("half.png");
850        // One pixel: pure red at 50% alpha.
851        image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 128]))
852            .save(&path)
853            .unwrap();
854
855        let straight = decode(&path, (10, 10), PixelFormat::Rgba8).unwrap();
856        assert_eq!(&straight.rgba[..], &[255, 0, 0, 128], "RGBA is untouched");
857
858        let bgra = decode(&path, (10, 10), PixelFormat::BgraPremultiplied8).unwrap();
859        // B, G, R, A with colour scaled by alpha: 255 * 128/255 = 128.
860        assert_eq!(
861            &bgra.rgba[..],
862            &[0, 0, 128, 128],
863            "channels swapped and premultiplied"
864        );
865    }
866
867    /// Rounding rather than truncating: `(c * a) / 255` alone darkens every
868    /// semi-transparent pixel by up to one level, which shows as a dingy
869    /// edge on antialiased artwork.
870    #[test]
871    fn premultiplication_rounds_instead_of_truncating() {
872        let mut px = vec![200u8, 100, 50, 200];
873        premultiply_to_bgra(&mut px);
874        // 200*200/255 = 156.86 -> 157 rounded (156 truncated).
875        assert_eq!(px[2], 157, "red channel rounded");
876        assert_eq!(px[3], 200, "alpha is left alone");
877    }
878
879    /// The format is part of the cache key: the same file wanted in two
880    /// layouts is two decodes, not one served in the wrong byte order.
881    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
882    async fn the_format_is_part_of_the_cache_key() {
883        let dir = tempfile::tempdir().unwrap();
884        let p = write_png(dir.path(), "c.png", 8, 8);
885        let cache = Arc::new(MediaCache::new(8 * 1024 * 1024));
886
887        let a = cache.get(&p, (100, 100), PixelFormat::Rgba8).await.unwrap();
888        let b = cache
889            .get(&p, (100, 100), PixelFormat::BgraPremultiplied8)
890            .await
891            .unwrap();
892        assert!(
893            !Arc::ptr_eq(&a, &b),
894            "different layouts are different entries"
895        );
896        assert_eq!(a.format, PixelFormat::Rgba8);
897        assert_eq!(b.format, PixelFormat::BgraPremultiplied8);
898    }
899
900    /// An edited image must reappear rather than serving the old decode
901    /// forever — which is what `mtime` is in the key for.
902    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
903    async fn rewriting_the_file_invalidates_its_entry() {
904        let dir = tempfile::tempdir().unwrap();
905        let p = write_png(dir.path(), "x.png", 40, 40);
906        let cache = Arc::new(MediaCache::new(8 * 1024 * 1024));
907        let first = cache.get(&p, (100, 100), PixelFormat::Rgba8).await.unwrap();
908        assert_eq!((first.width, first.height), (40, 40));
909
910        // Rewrite at a different size, with an mtime the filesystem will
911        // report as newer.
912        std::thread::sleep(std::time::Duration::from_millis(1100));
913        write_png(dir.path(), "x.png", 20, 20);
914
915        let second = cache.get(&p, (100, 100), PixelFormat::Rgba8).await.unwrap();
916        assert_eq!(
917            (second.width, second.height),
918            (20, 20),
919            "the rewritten file was decoded again, not served from cache"
920        );
921    }
922}