From 22a4ca613b9d635f2b485af91d51da4864833544 Mon Sep 17 00:00:00 2001
From: Sergio Gonzalez Martin <sergiog@microsoft.com>
Date: Wed, 18 Feb 2026 09:56:40 +0000
Subject: [PATCH] bmp/decoder: Add streaming/resumable BMP decoding support

Port upstream image crate BMP decoder improvements that add:

- Resumable streaming decoding with BmpDecoder::new_resumable()
- ICC color profile extraction support
- Phased metadata reading (headers, palette, ICC profile)
- Row-level and RLE checkpoint resumability on UnexpectedEof
- Proper state machine for decoder progress tracking
- Refactored header parsing into dedicated structs
- RowsDecoded enum to track decode progress
- Strict/lenient spec validation (BmpSpec enum)
- OS/2 BITMAPCOREHEADER2 (v2) support
- RLE24 (24-bit run length encoding) decoding
- BitfieldCompression enum for BI_ALPHABITFIELDS support
- Lenient handling for non-conformant BMPs (planes, top-down RLE,
  oversized palettes, zero bitfield masks)

These changes enable progressive BMP image decoding where data
arrives incrementally, which is essential for network image loading.

Upstream: https://github.com/image-rs/image

diff --git a/third_party/rust/chromium_crates_io/vendor/image-v0_25/src/codecs/bmp/decoder.rs b/third_party/rust/chromium_crates_io/vendor/image-v0_25/src/codecs/bmp/decoder.rs
index 5771665..00799e3 100644
--- a/third_party/rust/chromium_crates_io/vendor/image-v0_25/src/codecs/bmp/decoder.rs
+++ b/third_party/rust/chromium_crates_io/vendor/image-v0_25/src/codecs/bmp/decoder.rs
@@ -5,15 +5,23 @@ use std::iter::{repeat, Rev};
 use std::slice::ChunksExactMut;
 use std::{error, fmt};
 
-use byteorder_lite::{LittleEndian, ReadBytesExt};
-
 use crate::color::ColorType;
 use crate::error::{
     DecodingError, ImageError, ImageResult, UnsupportedError, UnsupportedErrorKind,
 };
-use crate::io::free_functions::load_rect;
-use crate::io::ReadExt;
-use crate::{ImageDecoder, ImageDecoderRect, ImageFormat};
+use crate::{ImageDecoder, ImageFormat};
+
+/// Controls how strictly the BMP decoder adheres to the specification.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub(crate) enum BmpSpec {
+    /// Strictly follow the BMP specification.
+    /// Rejects files that violate spec constraints (e.g., RLE with top-down).
+    Strict,
+    /// Allow some non-conformant files that violate some spec constraints
+    /// but still can be decoded at best effort.
+    #[default]
+    Lenient,
+}
 
 const BITMAPCOREHEADER_SIZE: u32 = 12;
 const BITMAPINFOHEADER_SIZE: u32 = 40;
@@ -22,6 +30,21 @@ const BITMAPV3HEADER_SIZE: u32 = 56;
 const BITMAPV4HEADER_SIZE: u32 = 108;
 const BITMAPV5HEADER_SIZE: u32 = 124;
 
+const OS2_V2_MAX_HEADER_SIZE: u32 = 64;
+const OS2_V2_MIN_HEADER_SIZE: u32 = 16;
+
+// Compression method constants
+const BI_RGB: u32 = 0;
+const BI_RLE8: u32 = 1;
+const BI_RLE4: u32 = 2;
+const BI_BITFIELDS: u32 = 3;
+const BI_JPEG: u32 = 4; // Used in legacy Windows pass-through printing path (not supported) and for RLE24
+const BI_PNG: u32 = 5; // Used in legacy Windows pass-through printing path - not supported
+const BI_ALPHABITFIELDS: u32 = 6;
+const BI_CMYK: u32 = 11;
+const BI_CMYKRLE8: u32 = 12;
+const BI_CMYKRLE4: u32 = 13;
+
 static LOOKUP_TABLE_3_BIT_TO_8_BIT: [u8; 8] = [0, 36, 73, 109, 146, 182, 219, 255];
 static LOOKUP_TABLE_4_BIT_TO_8_BIT: [u8; 16] = [
     0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255,
@@ -61,9 +84,224 @@ const RLE_ESCAPE_EOL: u8 = 0;
 const RLE_ESCAPE_EOF: u8 = 1;
 const RLE_ESCAPE_DELTA: u8 = 2;
 
+/// Opaque alpha channel value (fully opaque)
+const ALPHA_OPAQUE: u8 = 0xFF;
+
 /// The maximum width/height the decoder will process.
 const MAX_WIDTH_HEIGHT: i32 = 0xFFFF;
 
+/// The value of the V5 header field indicating an embedded ICC profile ("MBED").
+const PROFILE_EMBEDDED: u32 = 0x4D424544;
+
+/// During progressive decoding, the decoder applies transforms (e.g. a vertical
+/// flip for bottom-up BMP files) as it writes rows into the output buffer.
+/// This enum describes which rows contain valid pixel data by indicating the
+/// transform that was applied.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RowsDecoded {
+    /// Rows were decoded sequentially from the top of the image.
+    TopDown {
+        /// Number of top rows decoded so far.
+        rows: u32,
+    },
+    /// Rows were decoded from the bottom of the image (vertical flip).
+    BottomUp {
+        /// Number of bottom rows decoded so far.
+        rows: u32,
+    },
+}
+
+impl RowsDecoded {
+    /// Returns the number of decoded rows.
+    #[inline]
+    pub fn rows(&self) -> u32 {
+        match *self {
+            RowsDecoded::TopDown { rows } | RowsDecoded::BottomUp { rows } => rows,
+        }
+    }
+}
+
+/// Parsed BITMAPCOREHEADER fields (excludes 4-byte size field).
+struct ParsedCoreHeader {
+    width: i32,
+    height: i32,
+    bit_count: u16,
+    image_type: ImageType,
+}
+
+impl ParsedCoreHeader {
+    /// Parse BITMAPCOREHEADER fields from an 8-byte buffer.
+    fn parse(buffer: &[u8; 8], spec_strictness: BmpSpec) -> ImageResult<Self> {
+        let width = i32::from(u16::from_le_bytes(buffer[0..2].try_into().unwrap()));
+        let height = i32::from(u16::from_le_bytes(buffer[2..4].try_into().unwrap()));
+
+        let planes = u16::from_le_bytes(buffer[4..6].try_into().unwrap());
+        if spec_strictness == BmpSpec::Strict && planes != 1 {
+            return Err(DecoderError::MoreThanOnePlane.into());
+        }
+
+        let bit_count = u16::from_le_bytes(buffer[6..8].try_into().unwrap());
+        let image_type = match bit_count {
+            1 | 4 | 8 => ImageType::Palette,
+            24 => ImageType::RGB24,
+            _ => {
+                return Err(
+                    DecoderError::InvalidChannelWidth(ChannelWidthError::Rgb, bit_count).into(),
+                )
+            }
+        };
+
+        Ok(ParsedCoreHeader {
+            width,
+            height,
+            bit_count,
+            image_type,
+        })
+    }
+}
+
+/// Parsed BITMAPINFOHEADER fields (excludes 4-byte size field).
+struct ParsedInfoHeader {
+    width: i32,
+    height: i32,
+    top_down: bool,
+    bit_count: u16,
+    compression: u32,
+    colors_used: u32,
+}
+
+impl ParsedInfoHeader {
+    /// Parse BITMAPINFOHEADER fields from a 36-byte buffer.
+    fn parse(buffer: &[u8; 36], spec_strictness: BmpSpec) -> ImageResult<Self> {
+        let width = i32::from_le_bytes(buffer[0..4].try_into().unwrap());
+        let mut height = i32::from_le_bytes(buffer[4..8].try_into().unwrap());
+
+        // Width cannot be negative
+        if width < 0 {
+            return Err(DecoderError::NegativeWidth(width).into());
+        } else if width > MAX_WIDTH_HEIGHT || height > MAX_WIDTH_HEIGHT {
+            return Err(DecoderError::ImageTooLarge(width, height).into());
+        }
+
+        if height == i32::MIN {
+            return Err(DecoderError::InvalidHeight.into());
+        }
+
+        // A negative height indicates a top-down DIB
+        let top_down = if height < 0 {
+            height = -height;
+            true
+        } else {
+            false
+        };
+
+        let planes = u16::from_le_bytes(buffer[8..10].try_into().unwrap());
+        if spec_strictness == BmpSpec::Strict && planes != 1 {
+            return Err(DecoderError::MoreThanOnePlane.into());
+        }
+
+        let bit_count = u16::from_le_bytes(buffer[10..12].try_into().unwrap());
+        let compression = u32::from_le_bytes(buffer[12..16].try_into().unwrap());
+
+        // Top-down DIBs cannot be compressed (per BMP specification).
+        // In lenient mode, we allow this for compatibility with other decoders.
+        if spec_strictness == BmpSpec::Strict
+            && top_down
+            && compression != BI_RGB
+            && compression != BI_BITFIELDS
+            && compression != BI_ALPHABITFIELDS
+        {
+            return Err(DecoderError::ImageTypeInvalidForTopDown(compression).into());
+        }
+
+        // Skip size_image (16-19), x_pix_permeter (20-23), y_pix_permeter (24-27)
+        let colors_used = u32::from_le_bytes(buffer[28..32].try_into().unwrap());
+        // Skip important_colors (32-35)
+        Ok(ParsedInfoHeader {
+            width,
+            height,
+            top_down,
+            bit_count,
+            compression,
+            colors_used,
+        })
+    }
+}
+
+/// Parsed bitfield masks from DIB header.
+struct ParsedBitfields {
+    r_mask: u32,
+    g_mask: u32,
+    b_mask: u32,
+    a_mask: u32,
+}
+
+impl ParsedBitfields {
+    /// Parse bitfield masks from buffer.
+    /// Caller must ensure buffer has sufficient length; this method does not validate.
+    /// Note: Caller must ensure buffer has 12 (V2/Core) or 16 (V3/V4/V5) bytes length; this method does not validate.
+    #[track_caller]
+    fn parse(buffer: &[u8], has_alpha: bool) -> Self {
+        let r_mask = u32::from_le_bytes(buffer[0..4].try_into().unwrap());
+        let g_mask = u32::from_le_bytes(buffer[4..8].try_into().unwrap());
+        let b_mask = u32::from_le_bytes(buffer[8..12].try_into().unwrap());
+        let a_mask = if has_alpha {
+            u32::from_le_bytes(buffer[12..16].try_into().unwrap())
+        } else {
+            0
+        };
+
+        ParsedBitfields {
+            r_mask,
+            g_mask,
+            b_mask,
+            a_mask,
+        }
+    }
+}
+
+/// Parsed ICC profile metadata from V5 header.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct ParsedIccProfile {
+    /// Absolute file offset where the ICC profile data starts.
+    profile_offset: u64,
+    profile_size: u32,
+}
+
+impl ParsedIccProfile {
+    /// Parse ICC profile metadata from V5 header buffer.
+    /// Returns None if no embedded ICC profile is present.
+    /// Note: Caller must ensure buffer has 116 bytes length; this method does not validate.
+    #[track_caller]
+    fn parse(buffer: &[u8], bmp_header_offset: u64) -> Option<Self> {
+        // bV5CSType is at offset 56 from header start, which is offset 52 from after the size field
+        let cs_type = u32::from_le_bytes(buffer[52..56].try_into().unwrap());
+
+        // Only embedded profiles are supported
+        if cs_type != PROFILE_EMBEDDED {
+            return None;
+        }
+
+        // bV5ProfileData is at offset 112 from header start, which is offset 108 from after size field
+        let profile_offset_from_header = u32::from_le_bytes(buffer[108..112].try_into().unwrap());
+
+        // bV5ProfileSize is at offset 116 from header start, which is offset 112 from after size field
+        let profile_size = u32::from_le_bytes(buffer[112..116].try_into().unwrap());
+
+        if profile_size == 0 || profile_offset_from_header == 0 {
+            return None;
+        }
+
+        // Compute the absolute file offset by adding the header's position to the relative offset
+        let profile_offset = bmp_header_offset + u64::from(profile_offset_from_header);
+
+        Some(ParsedIccProfile {
+            profile_offset,
+            profile_size,
+        })
+    }
+}
+
 #[derive(PartialEq, Copy, Clone)]
 enum ImageType {
     Palette,
@@ -73,10 +311,87 @@ enum ImageType {
     RGBA32,
     RLE8,
     RLE4,
+    RLE24,
     Bitfields16,
     Bitfields32,
 }
 
+/// Progress within the metadata reading phase.
+///
+/// The metadata is split into phases:
+/// 1. Headers: File header, DIB header, and bitmasks (~30-150 bytes total).
+///    These are always re-read together on retry since they're small.
+/// 2. Optional data: Palette (up to 1KB) and ICC profile (variable, can be several KB).
+///    These are tracked separately since they can be larger.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+enum MetadataProgress {
+    /// Initial state, nothing read yet.
+    #[default]
+    NotStarted,
+    /// Reading main headers (file header, DIB header, bitmasks).
+    /// Stores the start offset for seeking on retry.
+    ReadingMainHeader { start_offset: u64 },
+    /// Headers have been read; now reading palette.
+    /// Stores header offsets for subsequent phases.
+    ReadingPalette { offsets: HeaderOffsets },
+    /// Headers and palette (if any) have been read; now reading ICC profile.
+    /// Stores header offsets for the ICC profile read.
+    ReadingIccProfile { offsets: HeaderOffsets },
+    /// All metadata has been read successfully.
+    Complete,
+}
+
+/// Offsets and sizes discovered during header parsing.
+/// Carried through metadata phases to avoid redundant state.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct HeaderOffsets {
+    /// Offset where palette data starts (after headers).
+    palette_offset: u64,
+    /// ICC profile metadata if present.
+    icc_profile: Option<ParsedIccProfile>,
+}
+
+/// Progress within the RLE decoding phase.
+///
+/// RLE decoding checkpoints at row boundaries (after EndOfRow markers) and
+/// after Delta instructions to avoid quadratic time with malformed files.
+/// On UnexpectedEof, decoding resumes from the last stored checkpoint.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+enum RleProgress {
+    /// Not started yet.
+    #[default]
+    NotStarted,
+    /// Checkpoint at position (row, x) with stream at stream_pos.
+    /// On resume, decoding continues from this exact pixel position.
+    Checkpoint { row: u32, x: u32, stream_pos: u64 },
+}
+
+/// Decoder state for resumable decoding.
+///
+/// This allows the decoder to recover from `UnexpectedEof` errors.
+/// Decoding can resume from the last successfully decoded row or RLE symbol.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum DecoderState {
+    /// Currently reading metadata (headers, palette, ICC profile).
+    ReadingMetadata { progress: MetadataProgress },
+    /// Currently reading row-based (non-RLE) image data.
+    /// Stores the number of rows successfully decoded.
+    ReadingRowData { rows_decoded: u32 },
+    /// Currently reading RLE-compressed data.
+    /// Tracks progress at symbol boundaries for resumability.
+    ReadingRleData { progress: RleProgress },
+    /// Image data has been fully decoded.
+    ImageDecoded,
+}
+
+impl Default for DecoderState {
+    fn default() -> Self {
+        DecoderState::ReadingMetadata {
+            progress: MetadataProgress::default(),
+        }
+    }
+}
+
 #[derive(PartialEq)]
 enum BMPHeaderType {
     Core,
@@ -85,6 +400,7 @@ enum BMPHeaderType {
     V3,
     V4,
     V5,
+    Os2V2,
 }
 
 #[derive(PartialEq)]
@@ -95,6 +411,15 @@ enum FormatFullBytes {
     Format888,
 }
 
+/// Compression type for bitfield-based formats.
+#[derive(PartialEq, Copy, Clone)]
+enum BitfieldCompression {
+    /// BI_BITFIELDS: RGB masks only (3 masks, 12 bytes after header).
+    Rgb,
+    /// BI_ALPHABITFIELDS: RGBA masks (4 masks, 16 bytes after header).
+    Rgba,
+}
+
 enum Chunker<'a> {
     FromTop(ChunksExactMut<'a, u8>),
     FromBottom(Rev<ChunksExactMut<'a, u8>>),
@@ -160,6 +485,9 @@ enum DecoderError {
         colors_used: u32,
         bit_count: u16,
     },
+
+    /// read_image_data was called before read_metadata completed
+    MetadataNotRead,
 }
 
 impl fmt::Display for DecoderError {
@@ -199,6 +527,9 @@ impl fmt::Display for DecoderError {
             } => f.write_fmt(format_args!(
                 "Palette size {colors_used} exceeds maximum size for BMP with bit count of {bit_count}"
             )),
+            DecoderError::MetadataNotRead => {
+                f.write_str("read_image_data called before read_metadata completed")
+            }
         }
     }
 }
@@ -220,6 +551,8 @@ enum ChannelWidthError {
     Rle8,
     /// 4-bit run length encoding
     Rle4,
+    /// 24-bit run length encoding (OS/2)
+    Rle24,
     /// Bitfields (16- or 32-bit)
     Bitfields,
 }
@@ -230,11 +563,33 @@ impl fmt::Display for ChannelWidthError {
             ChannelWidthError::Rgb => "RGB",
             ChannelWidthError::Rle8 => "RLE8",
             ChannelWidthError::Rle4 => "RLE4",
+            ChannelWidthError::Rle24 => "RLE24",
             ChannelWidthError::Bitfields => "bitfields",
         })
     }
 }
 
+/// BMP rows must be padded to a multiple of 4 bytes.
+#[inline]
+fn calculate_row_padding(bytes_per_row: usize) -> usize {
+    (4 - (bytes_per_row % 4)) % 4
+}
+
+/// Allocate a row buffer with OOM protection.
+fn allocate_row_buffer(size: usize) -> ImageResult<Vec<u8>> {
+    let mut buffer = vec_try_with_capacity(size).map_err(|_| {
+        ImageError::Unsupported(UnsupportedError::from_format_and_kind(
+            ImageFormat::Bmp.into(),
+            UnsupportedErrorKind::GenericFeature(format!(
+                "Row buffer allocation ({} bytes) too large",
+                size
+            )),
+        ))
+    })?;
+    buffer.resize(size, 0);
+    Ok(buffer)
+}
+
 /// Convenience function to check if the combination of width, length and number of
 /// channels would result in a buffer that would overflow.
 fn check_for_overflow(width: i32, length: i32, channels: usize) -> ImageResult<()> {
@@ -263,35 +618,58 @@ fn num_bytes(width: i32, length: i32, channels: usize) -> Option<usize> {
     }
 }
 
-/// Call the provided function on each row of the provided buffer, returning Err if the provided
-/// function returns an error, extends the buffer if it's not large enough.
-fn with_rows<F>(
+/// Process rows with resumability support.
+///
+/// Calls `func` for each row from `start_row` to `height`, passing the output row slice.
+/// On success, returns the total number of rows (height).
+/// On error, returns the number of rows successfully completed before the error.
+///
+/// The caller is responsible for seeking to the correct file position before calling.
+fn with_rows_resumable<F>(
     buffer: &mut [u8],
     width: i32,
     height: i32,
     channels: usize,
     top_down: bool,
+    start_row: u32,
     mut func: F,
-) -> io::Result<()>
+) -> Result<u32, (u32, io::Error)>
 where
     F: FnMut(&mut [u8]) -> io::Result<()>,
 {
     // An overflow should already have been checked for when this is called,
     // though we check anyhow, as it somehow seems to increase performance slightly.
     let row_width = channels.checked_mul(width as usize).unwrap();
-    let full_image_size = row_width.checked_mul(height as usize).unwrap();
-    assert_eq!(buffer.len(), full_image_size);
-
-    if !top_down {
-        for row in buffer.chunks_mut(row_width).rev() {
-            func(row)?;
+    let height = height as u32;
+
+    /// Get the index of a row in the output buffer given the file row index.
+    /// For top-down images, row 0 in the file is row 0 in the buffer.
+    /// For bottom-up images, row 0 in the file is the last row in the buffer.
+    #[inline]
+    fn output_row_index(file_row: u32, height: u32, top_down: bool) -> usize {
+        if top_down {
+            file_row as usize
+        } else {
+            (height - 1 - file_row) as usize
         }
-    } else {
-        for row in buffer.chunks_mut(row_width) {
-            func(row)?;
+    }
+
+    /// Get a mutable reference to a specific row in the output buffer.
+    #[inline]
+    fn get_row_mut(buf: &mut [u8], row_index: usize, row_stride: usize) -> &mut [u8] {
+        let start = row_index * row_stride;
+        &mut buf[start..][..row_stride]
+    }
+
+    for file_row in start_row..height {
+        let out_row_idx = output_row_index(file_row, height, top_down);
+        let row = get_row_mut(buffer, out_row_idx, row_width);
+
+        if let Err(e) = func(row) {
+            return Err((file_row, e));
         }
     }
-    Ok(())
+    Ok(height)
 }
 
 fn set_8bit_pixel_run<'a, T: Iterator<Item = &'a u8>>(
@@ -428,6 +806,7 @@ impl Bitfield {
     fn read(&self, data: u32) -> u8 {
         let data = data >> self.shift;
         match self.len {
+            0 => 0,
             1 => ((data & 0b1) * 0xff) as u8,
             2 => ((data & 0b11) * 0x55) as u8,
             3 => LOOKUP_TABLE_3_BIT_TO_8_BIT[(data & 0b00_0111) as usize],
@@ -456,6 +835,7 @@ impl Bitfields {
         b_mask: u32,
         a_mask: u32,
         max_len: u32,
+        spec_strictness: BmpSpec,
     ) -> ImageResult<Bitfields> {
         let bitfields = Bitfields {
             r: Bitfield::from_mask(r_mask, max_len)?,
@@ -463,13 +843,76 @@ impl Bitfields {
             b: Bitfield::from_mask(b_mask, max_len)?,
             a: Bitfield::from_mask(a_mask, max_len)?,
         };
-        if bitfields.r.len == 0 || bitfields.g.len == 0 || bitfields.b.len == 0 {
+        // In strict mode, all RGB channels must have non-zero masks.
+        // In lenient mode, allow zero masks (the channel will read as 0).
+        if spec_strictness == BmpSpec::Strict
+            && (bitfields.r.len == 0 || bitfields.g.len == 0 || bitfields.b.len == 0)
+        {
             return Err(DecoderError::BitfieldMaskMissing(max_len).into());
         }
         Ok(bitfields)
     }
 }
 
+/// Helper to read RLE data using the already-buffered reader.
+/// Avoids double-buffering since BmpDecoder already requires BufRead.
+struct RleReader<'a, R> {
+    reader: &'a mut R,
+    bytes_read: u64,
+}
+
+impl<'a, R: BufRead> RleReader<'a, R> {
+    fn new(reader: &'a mut R) -> Self {
+        Self {
+            reader,
+            bytes_read: 0,
+        }
+    }
+
+    /// Total bytes consumed since this reader was created.
+    fn bytes_read(&self) -> u64 {
+        self.bytes_read
+    }
+
+    fn read_byte(&mut self) -> io::Result<u8> {
+        let buf = self.reader.fill_buf()?;
+        if buf.is_empty() {
+            return Err(io::Error::new(
+                io::ErrorKind::UnexpectedEof,
+                "unexpected end of RLE data",
+            ));
+        }
+        let byte = buf[0];
+        self.reader.consume(1);
+        self.bytes_read += 1;
+        Ok(byte)
+    }
+
+    fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()> {
+        let mut remaining = buf.len();
+        let mut offset = 0;
+
+        while remaining > 0 {
+            let available = self.reader.fill_buf()?;
+            if available.is_empty() {
+                return Err(io::Error::new(
+                    io::ErrorKind::UnexpectedEof,
+                    "unexpected end of RLE data",
+                ));
+            }
+
+            let to_read = remaining.min(available.len());
+            buf[offset..offset + to_read].copy_from_slice(&available[..to_read]);
+            self.reader.consume(to_read);
+            self.bytes_read += to_read as u64;
+            offset += to_read;
+            remaining -= to_read;
+        }
+
+        Ok(())
+    }
+}
+
 /// A bmp decoder
 pub struct BmpDecoder<R> {
     reader: R,
@@ -483,21 +926,17 @@ pub struct BmpDecoder<R> {
     top_down: bool,
     no_file_header: bool,
     add_alpha_channel: bool,
-    has_loaded_metadata: bool,
     image_type: ImageType,
 
     bit_count: u16,
     colors_used: u32,
     palette: Option<Vec<[u8; 3]>>,
     bitfields: Option<Bitfields>,
-}
+    icc_profile: Option<Vec<u8>>,
+    spec_strictness: BmpSpec,
 
-enum RLEInsn {
-    EndOfFile,
-    EndOfRow,
-    Delta(u8, u8),
-    Absolute(u8, Vec<u8>),
-    PixelRun(u8, u8),
+    /// Current decoder state for resumable decoding.
+    state: DecoderState,
 }
 
 impl<R: BufRead + Seek> BmpDecoder<R> {
@@ -514,13 +953,15 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
             top_down: false,
             no_file_header: false,
             add_alpha_channel: false,
-            has_loaded_metadata: false,
             image_type: ImageType::Palette,
 
             bit_count: 0,
             colors_used: 0,
             palette: None,
             bitfields: None,
+            icc_profile: None,
+            spec_strictness: BmpSpec::default(),
+            state: DecoderState::default(),
         }
     }
 
@@ -531,6 +972,60 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
         Ok(decoder)
     }
 
+    /// Create a new decoder that decodes from the stream `r` without reading
+    /// metadata immediately. This allows for resumable decoding when the
+    /// underlying reader may return `UnexpectedEof`.
+    ///
+    /// After creating the decoder, call `read_metadata()` to read the BMP
+    /// headers. If it returns an `UnexpectedEof` error, you can retry on the
+    /// same decoder instance after more data becomes available.
+    ///
+    /// Once metadata is read, call `read_image_data()` to read the pixel data.
+    /// This also supports retrying on `UnexpectedEof`.
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// use image::codecs::bmp::BmpDecoder;
+    /// use image::error::ImageError;
+    /// use image::ImageDecoder;
+    /// use std::io;
+    ///
+    /// fn is_unexpected_eof(err: &ImageError) -> bool {
+    ///     matches!(err, ImageError::IoError(e) if e.kind() == io::ErrorKind::UnexpectedEof)
+    /// }
+    ///
+    /// let mut decoder = BmpDecoder::new_resumable(reader);
+    ///
+    /// // Phase 1: Read metadata (with retry on UnexpectedEof)
+    /// loop {
+    ///     match decoder.read_metadata() {
+    ///         Ok(()) => break,
+    ///         Err(ref e) if is_unexpected_eof(e) => {
+    ///             // Wait for more data and retry on same decoder
+    ///             continue;
+    ///         }
+    ///         Err(e) => return Err(e),
+    ///     }
+    /// }
+    ///
+    /// // Phase 2: Read image data (with retry on UnexpectedEof)
+    /// let mut buf = vec![0u8; decoder.total_bytes() as usize];
+    /// loop {
+    ///     match decoder.read_image_data(&mut buf) {
+    ///         Ok(()) => break,
+    ///         Err(ref e) if is_unexpected_eof(e) => {
+    ///             // Wait for more data and retry on same decoder
+    ///             continue;
+    ///         }
+    ///         Err(e) => return Err(e),
+    ///     }
+    /// }
+    /// ```
+    pub fn new_resumable(reader: R) -> BmpDecoder<R> {
+        Self::new_decoder(reader)
+    }
+
     /// Create a new decoder that decodes from the stream ```r``` without first
     /// reading a BITMAPFILEHEADER. This is useful for decoding the `CF_DIB` format
     /// directly from the Windows clipboard.
@@ -563,51 +1058,144 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
         if self.no_file_header {
             return Ok(());
         }
-        let mut signature = [0; 2];
-        self.reader.read_exact(&mut signature)?;
 
-        if signature != b"BM"[..] {
+        // Read entire 14-byte file header
+        const FILE_HEADER_SIZE: usize = 14;
+        let mut buffer = [0u8; FILE_HEADER_SIZE];
+        self.reader.read_exact(&mut buffer)?;
+
+        // Check signature
+        if &buffer[0..2] != b"BM" {
             return Err(DecoderError::BmpSignatureInvalid.into());
         }
 
-        // The next 8 bytes represent file size, followed the 4 reserved bytes
-        // We're not interesting these values
-        self.reader.read_u32::<LittleEndian>()?;
-        self.reader.read_u32::<LittleEndian>()?;
-
-        self.data_offset = u64::from(self.reader.read_u32::<LittleEndian>()?);
+        // Skip file size (4 bytes) and reserved (4 bytes) at offsets 2-9
+        // Extract data_offset from bytes 10-13
+        let data_offset = u32::from_le_bytes([buffer[10], buffer[11], buffer[12], buffer[13]]);
+        self.data_offset = u64::from(data_offset);
 
         Ok(())
     }
 
+    /// Determine the image type from the compression method, bit count, and header type.
+    fn image_type_from_compression(
+        compression: u32,
+        bit_count: u16,
+        add_alpha_channel: bool,
+        header_type: &BMPHeaderType,
+    ) -> ImageResult<ImageType> {
+        match compression {
+            BI_RGB => match bit_count {
+                1 | 2 | 4 | 8 => Ok(ImageType::Palette),
+                16 => Ok(ImageType::RGB16),
+                24 => Ok(ImageType::RGB24),
+                32 if add_alpha_channel => Ok(ImageType::RGBA32),
+                32 => Ok(ImageType::RGB32),
+                _ => {
+                    Err(DecoderError::InvalidChannelWidth(ChannelWidthError::Rgb, bit_count).into())
+                }
+            },
+            BI_RLE8 => match bit_count {
+                8 => Ok(ImageType::RLE8),
+                _ => Err(
+                    DecoderError::InvalidChannelWidth(ChannelWidthError::Rle8, bit_count).into(),
+                ),
+            },
+            BI_RLE4 => match bit_count {
+                4 => Ok(ImageType::RLE4),
+                _ => Err(
+                    DecoderError::InvalidChannelWidth(ChannelWidthError::Rle4, bit_count).into(),
+                ),
+            },
+            BI_BITFIELDS | BI_ALPHABITFIELDS => match bit_count {
+                16 => Ok(ImageType::Bitfields16),
+                32 => Ok(ImageType::Bitfields32),
+                _ => Err(DecoderError::InvalidChannelWidth(
+                    ChannelWidthError::Bitfields,
+                    bit_count,
+                )
+                .into()),
+            },
+            BI_JPEG if *header_type == BMPHeaderType::Os2V2 && bit_count == 24 => {
+                Ok(ImageType::RLE24)
+            }
+            BI_JPEG if *header_type == BMPHeaderType::Os2V2 => {
+                Err(DecoderError::InvalidChannelWidth(ChannelWidthError::Rle24, bit_count).into())
+            }
+            BI_JPEG => Err(ImageError::Unsupported(
+                UnsupportedError::from_format_and_kind(
+                    ImageFormat::Bmp.into(),
+                    UnsupportedErrorKind::GenericFeature("JPEG compression".to_owned()),
+                ),
+            )),
+            BI_PNG => Err(ImageError::Unsupported(
+                UnsupportedError::from_format_and_kind(
+                    ImageFormat::Bmp.into(),
+                    UnsupportedErrorKind::GenericFeature("PNG compression".to_owned()),
+                ),
+            )),
+            BI_CMYK | BI_CMYKRLE4 | BI_CMYKRLE8 => Err(ImageError::Unsupported(
+                UnsupportedError::from_format_and_kind(
+                    ImageFormat::Bmp.into(),
+                    UnsupportedErrorKind::GenericFeature("CMYK format".to_owned()),
+                ),
+            )),
+            _ => Err(DecoderError::ImageTypeUnknown(compression).into()),
+        }
+    }
+
     /// Read BITMAPCOREHEADER <https://msdn.microsoft.com/en-us/library/vs/alm/dd183372(v=vs.85).aspx>
     ///
     /// returns Err if any of the values are invalid.
     fn read_bitmap_core_header(&mut self) -> ImageResult<()> {
-        // As height/width values in BMP files with core headers are only 16 bits long,
-        // they won't be larger than `MAX_WIDTH_HEIGHT`.
-        self.width = i32::from(self.reader.read_u16::<LittleEndian>()?);
-        self.height = i32::from(self.reader.read_u16::<LittleEndian>()?);
+        // Core header (after size field): width(2), height(2), planes(2), bitcount(2) = 8 bytes
+        let mut buffer = [0u8; 8];
+        self.reader.read_exact(&mut buffer)?;
+
+        let parsed = ParsedCoreHeader::parse(&buffer, self.spec_strictness)?;
+
+        self.width = parsed.width;
+        self.height = parsed.height;
+        self.bit_count = parsed.bit_count;
+        self.image_type = parsed.image_type;
 
         check_for_overflow(self.width, self.height, self.num_channels())?;
 
-        // Number of planes (format specifies that this should be 1).
-        if self.reader.read_u16::<LittleEndian>()? != 1 {
-            return Err(DecoderError::MoreThanOnePlane.into());
+        Ok(())
+    }
+
+    /// Read OS/2 BITMAPCOREHEADER2 (variable size 16-64 bytes, layout-compatible
+    /// with BITMAPINFOHEADER). Fields beyond the header size default to 0.
+    fn read_bitmap_os2v2_header(&mut self, header_size: u32) -> ImageResult<()> {
+        let remaining = (header_size - 4) as usize;
+
+        // Zero-pad to 36 bytes for ParsedInfoHeader::parse.
+        let mut buffer = [0u8; 36];
+        let to_read = remaining.min(36);
+        self.reader.read_exact(&mut buffer[..to_read])?;
+
+        // Skip OS/2-specific fields beyond the BITMAPINFOHEADER portion (max 28 bytes).
+        if remaining > 36 {
+            let skip = remaining - 36;
+            let mut discard = [0u8; 28];
+            self.reader.read_exact(&mut discard[..skip])?;
         }
 
-        self.bit_count = self.reader.read_u16::<LittleEndian>()?;
-        self.image_type = match self.bit_count {
-            1 | 4 | 8 => ImageType::Palette,
-            24 => ImageType::RGB24,
-            _ => {
-                return Err(DecoderError::InvalidChannelWidth(
-                    ChannelWidthError::Rgb,
-                    self.bit_count,
-                )
-                .into())
-            }
-        };
+        let parsed = ParsedInfoHeader::parse(&buffer, self.spec_strictness)?;
+
+        self.width = parsed.width;
+        self.height = parsed.height;
+        self.top_down = parsed.top_down;
+        self.bit_count = parsed.bit_count;
+        self.colors_used = parsed.colors_used;
+        self.image_type = Self::image_type_from_compression(
+            parsed.compression,
+            parsed.bit_count,
+            self.add_alpha_channel,
+            &self.bmp_header_type,
+        )?;
+
+        check_for_overflow(self.width, self.height, self.num_channels())?;
 
         Ok(())
     }
@@ -615,251 +1203,302 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
     /// Read BITMAPINFOHEADER <https://msdn.microsoft.com/en-us/library/vs/alm/dd183376(v=vs.85).aspx>
     /// or BITMAPV{2|3|4|5}HEADER.
     ///
-    /// returns Err if any of the values are invalid.
-    fn read_bitmap_info_header(&mut self) -> ImageResult<()> {
-        self.width = self.reader.read_i32::<LittleEndian>()?;
-        self.height = self.reader.read_i32::<LittleEndian>()?;
-
-        // Width can not be negative
-        if self.width < 0 {
-            return Err(DecoderError::NegativeWidth(self.width).into());
-        } else if self.width > MAX_WIDTH_HEIGHT || self.height > MAX_WIDTH_HEIGHT {
-            // Limit very large image sizes to avoid OOM issues. Images with these sizes are
-            // unlikely to be valid anyhow.
-            return Err(DecoderError::ImageTooLarge(self.width, self.height).into());
-        }
-
-        if self.height == i32::MIN {
-            return Err(DecoderError::InvalidHeight.into());
-        }
-
-        // A negative height indicates a top-down DIB.
-        if self.height < 0 {
-            self.height *= -1;
-            self.top_down = true;
-        }
+    /// Returns the bitfield compression type or Err if any of the values are invalid.
+    fn read_bitmap_info_header(&mut self) -> ImageResult<BitfieldCompression> {
+        // Info header (after size field): 36 bytes minimum
+        let mut buffer = [0u8; 36];
+        self.reader.read_exact(&mut buffer)?;
+
+        let parsed = ParsedInfoHeader::parse(&buffer, self.spec_strictness)?;
+
+        self.width = parsed.width;
+        self.height = parsed.height;
+        self.top_down = parsed.top_down;
+        self.bit_count = parsed.bit_count;
+        self.colors_used = parsed.colors_used;
+        self.image_type = Self::image_type_from_compression(
+            parsed.compression,
+            parsed.bit_count,
+            self.add_alpha_channel,
+            &self.bmp_header_type,
+        )?;
 
         check_for_overflow(self.width, self.height, self.num_channels())?;
 
-        // Number of planes (format specifies that this should be 1).
-        if self.reader.read_u16::<LittleEndian>()? != 1 {
-            return Err(DecoderError::MoreThanOnePlane.into());
-        }
-
-        self.bit_count = self.reader.read_u16::<LittleEndian>()?;
-        let image_type_u32 = self.reader.read_u32::<LittleEndian>()?;
-
-        // Top-down dibs can not be compressed.
-        if self.top_down && image_type_u32 != 0 && image_type_u32 != 3 {
-            return Err(DecoderError::ImageTypeInvalidForTopDown(image_type_u32).into());
-        }
-        self.image_type = match image_type_u32 {
-            0 => match self.bit_count {
-                1 | 2 | 4 | 8 => ImageType::Palette,
-                16 => ImageType::RGB16,
-                24 => ImageType::RGB24,
-                32 if self.add_alpha_channel => ImageType::RGBA32,
-                32 => ImageType::RGB32,
-                _ => {
-                    return Err(DecoderError::InvalidChannelWidth(
-                        ChannelWidthError::Rgb,
-                        self.bit_count,
-                    )
-                    .into())
-                }
-            },
-            1 => match self.bit_count {
-                8 => ImageType::RLE8,
-                _ => {
-                    return Err(DecoderError::InvalidChannelWidth(
-                        ChannelWidthError::Rle8,
-                        self.bit_count,
-                    )
-                    .into())
-                }
-            },
-            2 => match self.bit_count {
-                4 => ImageType::RLE4,
-                _ => {
-                    return Err(DecoderError::InvalidChannelWidth(
-                        ChannelWidthError::Rle4,
-                        self.bit_count,
-                    )
-                    .into())
-                }
-            },
-            3 => match self.bit_count {
-                16 => ImageType::Bitfields16,
-                32 => ImageType::Bitfields32,
-                _ => {
-                    return Err(DecoderError::InvalidChannelWidth(
-                        ChannelWidthError::Bitfields,
-                        self.bit_count,
-                    )
-                    .into())
-                }
-            },
-            4 => {
-                // JPEG compression is not implemented yet.
-                return Err(ImageError::Unsupported(
-                    UnsupportedError::from_format_and_kind(
-                        ImageFormat::Bmp.into(),
-                        UnsupportedErrorKind::GenericFeature("JPEG compression".to_owned()),
-                    ),
-                ));
-            }
-            5 => {
-                // PNG compression is not implemented yet.
-                return Err(ImageError::Unsupported(
-                    UnsupportedError::from_format_and_kind(
-                        ImageFormat::Bmp.into(),
-                        UnsupportedErrorKind::GenericFeature("PNG compression".to_owned()),
-                    ),
-                ));
-            }
-            11..=13 => {
-                // CMYK types are not implemented yet.
-                return Err(ImageError::Unsupported(
-                    UnsupportedError::from_format_and_kind(
-                        ImageFormat::Bmp.into(),
-                        UnsupportedErrorKind::GenericFeature("CMYK format".to_owned()),
-                    ),
-                ));
-            }
-            _ => {
-                // Unknown compression type.
-                return Err(DecoderError::ImageTypeUnknown(image_type_u32).into());
-            }
+        let compression = match parsed.compression {
+            BI_ALPHABITFIELDS => BitfieldCompression::Rgba,
+            _ => BitfieldCompression::Rgb,
         };
-
-        // The next 12 bytes represent data array size in bytes,
-        // followed the horizontal and vertical printing resolutions
-        // We will calculate the pixel array size using width & height of image
-        // We're not interesting the horz or vert printing resolutions
-        self.reader.read_u32::<LittleEndian>()?;
-        self.reader.read_u32::<LittleEndian>()?;
-        self.reader.read_u32::<LittleEndian>()?;
-
-        self.colors_used = self.reader.read_u32::<LittleEndian>()?;
-
-        // The next 4 bytes represent number of "important" colors
-        // We're not interested in this value, so we'll skip it
-        self.reader.read_u32::<LittleEndian>()?;
-
-        Ok(())
+        Ok(compression)
     }
 
-    fn read_bitmasks(&mut self) -> ImageResult<()> {
-        let r_mask = self.reader.read_u32::<LittleEndian>()?;
-        let g_mask = self.reader.read_u32::<LittleEndian>()?;
-        let b_mask = self.reader.read_u32::<LittleEndian>()?;
+    fn read_bitmasks(&mut self, compression: BitfieldCompression) -> ImageResult<()> {
+        // Determine if we need to read alpha mask:
+        // - V3/V4/V5 headers have the alpha mask embedded in the header
+        // - BI_ALPHABITFIELDS compression has a 4th mask after the header
+        let has_alpha = matches!(
+            self.bmp_header_type,
+            BMPHeaderType::V3 | BMPHeaderType::V4 | BMPHeaderType::V5
+        ) || compression == BitfieldCompression::Rgba;
 
-        let a_mask = match self.bmp_header_type {
-            BMPHeaderType::V3 | BMPHeaderType::V4 | BMPHeaderType::V5 => {
-                self.reader.read_u32::<LittleEndian>()?
-            }
-            _ => 0,
-        };
+        // Read bitfield masks into buffer
+        let buffer_size = if has_alpha { 16 } else { 12 };
+        let mut buffer = vec![0u8; buffer_size];
+        self.reader.read_exact(&mut buffer)?;
 
+        // Parse masks using shared logic
+        let parsed = ParsedBitfields::parse(&buffer, has_alpha);
+
+        // Create Bitfields from parsed masks
         self.bitfields = match self.image_type {
-            ImageType::Bitfields16 => {
-                Some(Bitfields::from_mask(r_mask, g_mask, b_mask, a_mask, 16)?)
-            }
-            ImageType::Bitfields32 => {
-                Some(Bitfields::from_mask(r_mask, g_mask, b_mask, a_mask, 32)?)
+            ImageType::Bitfields16 | ImageType::Bitfields32 => {
+                let max_len = match self.image_type {
+                    ImageType::Bitfields16 => 16,
+                    ImageType::Bitfields32 => 32,
+                    _ => unreachable!(),
+                };
+                Some(Bitfields::from_mask(
+                    parsed.r_mask,
+                    parsed.g_mask,
+                    parsed.b_mask,
+                    parsed.a_mask,
+                    max_len,
+                    self.spec_strictness,
+                )?)
             }
             _ => None,
         };
 
-        if self.bitfields.is_some() && a_mask != 0 {
+        if self.bitfields.is_some() && parsed.a_mask != 0 {
             self.add_alpha_channel = true;
         }
 
         Ok(())
     }
 
-    fn read_metadata(&mut self) -> ImageResult<()> {
-        if !self.has_loaded_metadata {
-            self.read_file_header()?;
-            let bmp_header_offset = self.reader.stream_position()?;
-            let bmp_header_size = self.reader.read_u32::<LittleEndian>()?;
-            let bmp_header_end = bmp_header_offset + u64::from(bmp_header_size);
-
-            self.bmp_header_type = match bmp_header_size {
-                BITMAPCOREHEADER_SIZE => BMPHeaderType::Core,
-                BITMAPINFOHEADER_SIZE => BMPHeaderType::Info,
-                BITMAPV2HEADER_SIZE => BMPHeaderType::V2,
-                BITMAPV3HEADER_SIZE => BMPHeaderType::V3,
-                BITMAPV4HEADER_SIZE => BMPHeaderType::V4,
-                BITMAPV5HEADER_SIZE => BMPHeaderType::V5,
-                _ if bmp_header_size < BITMAPCOREHEADER_SIZE => {
-                    // Size of any valid header types won't be smaller than core header type.
-                    return Err(DecoderError::HeaderTooSmall(bmp_header_size).into());
-                }
-                _ => {
-                    return Err(ImageError::Unsupported(
-                        UnsupportedError::from_format_and_kind(
-                            ImageFormat::Bmp.into(),
-                            UnsupportedErrorKind::GenericFeature(format!(
-                                "Unknown bitmap header type (size={bmp_header_size})"
-                            )),
-                        ),
-                    ))
-                }
-            };
-
-            match self.bmp_header_type {
-                BMPHeaderType::Core => {
-                    self.read_bitmap_core_header()?;
-                }
-                BMPHeaderType::Info
-                | BMPHeaderType::V2
-                | BMPHeaderType::V3
-                | BMPHeaderType::V4
-                | BMPHeaderType::V5 => {
-                    self.read_bitmap_info_header()?;
-                }
-            }
-
-            let mut bitmask_bytes_offset = 0;
-            if self.image_type == ImageType::Bitfields16
-                || self.image_type == ImageType::Bitfields32
-            {
-                self.read_bitmasks()?;
-
-                // Per https://learn.microsoft.com/en-us/windows/win32/gdi/bitmap-header-types, bitmaps
-                // using the `BITMAPINFOHEADER`, `BITMAPV4HEADER`, or `BITMAPV5HEADER` structures with
-                // an image type of `BI_BITFIELD` contain RGB bitfield masks immediately after the header.
-                //
-                // `read_bitmasks` correctly reads these from earlier in the header itself but we must
-                // ensure the reader starts on the image data itself, not these extra mask bytes.
-                if matches!(
-                    self.bmp_header_type,
-                    BMPHeaderType::Info | BMPHeaderType::V4 | BMPHeaderType::V5
-                ) {
-                    // This is `size_of::<u32>() * 3` (a red, green, and blue mask), but with less noise.
-                    bitmask_bytes_offset = 12;
-                }
-            };
-
-            self.reader
-                .seek(SeekFrom::Start(bmp_header_end + bitmask_bytes_offset))?;
-
-            match self.image_type {
-                ImageType::Palette | ImageType::RLE4 | ImageType::RLE8 => self.read_palette()?,
-                _ => {}
-            }
-
-            if self.no_file_header {
-                // Use the offset of the end of metadata instead of reading a BMP file header.
-                self.data_offset = self.reader.stream_position()?;
-            }
-
-            self.has_loaded_metadata = true;
-        }
+    /// Read ICC profile data from the file.
+    fn read_icc_profile(&mut self, icc: &ParsedIccProfile) -> ImageResult<()> {
+        self.reader.seek(SeekFrom::Start(icc.profile_offset))?;
+        let mut profile_data = vec![0u8; icc.profile_size as usize];
+        self.reader.read_exact(&mut profile_data)?;
+        self.icc_profile = Some(profile_data);
         Ok(())
     }
 
+    /// Read BMP metadata (headers, palette, etc.).
+    ///
+    /// On `UnexpectedEof`, the decoder can be retried - the implementation tracks
+    /// progress and resumes from where it left off. Once successful, subsequent
+    /// calls are no-ops.
+    ///
+    /// Metadata reading is divided into phases:
+    /// 1. Headers: File header, DIB header, and bitmasks (~30-150 bytes).
+    ///    These are re-read together on retry since they're small.
+    /// 2. Palette: Up to 1KB for indexed color images.
+    /// 3. ICC profile: Variable size, can be several KB (V5 headers only).
+    pub fn read_metadata(&mut self) -> ImageResult<()> {
+        // Check if we're in a metadata reading state
+        let DecoderState::ReadingMetadata { progress } = self.state else {
+            return Ok(()); // Already past metadata phase
+        };
+
+        match self.read_metadata_impl(progress) {
+            Ok(()) => {
+                // Transition directly to the appropriate image reading state
+                self.state = if self.is_rle() {
+                    DecoderState::ReadingRleData {
+                        progress: RleProgress::NotStarted,
+                    }
+                } else {
+                    DecoderState::ReadingRowData { rows_decoded: 0 }
+                };
+                Ok(())
+            }
+            Err(e) => Err(e),
+        }
+    }
+
+    /// Internal implementation of metadata reading with phased resumability.
+    ///
+    /// Uses recursive calls to progress through phases. Each phase either:
+    /// - Succeeds and calls the next phase
+    /// - Fails with an error (which may be retryable like UnexpectedEof)
+    ///
+    /// Recursion depth is bounded (max 4): NotStarted → ReadingMainHeader → ReadingPalette → ReadingIccProfile → Complete
+    fn read_metadata_impl(&mut self, progress: MetadataProgress) -> ImageResult<()> {
+        match progress {
+            MetadataProgress::NotStarted => {
+                // Record current position and transition to ReadingMainHeader
+                let start_offset = self.reader.stream_position()?;
+                let next = MetadataProgress::ReadingMainHeader { start_offset };
+                self.state = DecoderState::ReadingMetadata { progress: next };
+                self.read_metadata_impl(next)
+            }
+            MetadataProgress::ReadingMainHeader { start_offset } => {
+                // Seek to start position (for retry support)
+                self.reader.seek(SeekFrom::Start(start_offset))?;
+
+                // Read headers and get offsets for subsequent phases
+                let offsets = self.read_headers()?;
+
+                // Always progress to ReadingPalette next
+                let next = MetadataProgress::ReadingPalette { offsets };
+                self.state = DecoderState::ReadingMetadata { progress: next };
+                self.read_metadata_impl(next)
+            }
+            MetadataProgress::ReadingPalette { offsets } => {
+                // Always seek to palette position (this is also where image data starts
+                // for non-palette formats)
+                self.reader.seek(SeekFrom::Start(offsets.palette_offset))?;
+
+                // Read palette if needed for this image type
+                if matches!(
+                    self.image_type,
+                    ImageType::Palette | ImageType::RLE4 | ImageType::RLE8
+                ) {
+                    self.read_palette()?;
+                }
+
+                // For no_file_header mode, capture data_offset now (after palette read)
+                // before ICC profile reading potentially changes reader position
+                if self.no_file_header {
+                    self.data_offset = self.reader.stream_position()?;
+                }
+
+                // Always progress to ReadingIccProfile next
+                let next = MetadataProgress::ReadingIccProfile { offsets };
+                self.state = DecoderState::ReadingMetadata { progress: next };
+                self.read_metadata_impl(next)
+            }
+            MetadataProgress::ReadingIccProfile { offsets } => {
+                // Read ICC profile if present
+                if let Some(ref icc) = offsets.icc_profile {
+                    self.read_icc_profile(icc)?;
+                }
+
+                // Always progress to Complete next
+                self.state = DecoderState::ReadingMetadata {
+                    progress: MetadataProgress::Complete,
+                };
+                self.read_metadata_impl(MetadataProgress::Complete)
+            }
+            MetadataProgress::Complete => Ok(()),
+        }
+    }
+
+    /// Read headers phase: file header, DIB header, and bitmasks.
+    /// Returns HeaderOffsets containing positions for subsequent phases.
+    fn read_headers(&mut self) -> ImageResult<HeaderOffsets> {
+        self.read_file_header()?;
+        let bmp_header_offset = self.reader.stream_position()?;
+
+        // Read header size into buffer for consistency with buffer-based pattern
+        let mut size_buffer = [0u8; 4];
+        self.reader.read_exact(&mut size_buffer)?;
+        let bmp_header_size = u32::from_le_bytes(size_buffer);
+
+        let bmp_header_end = bmp_header_offset + u64::from(bmp_header_size);
+
+        self.bmp_header_type = match bmp_header_size {
+            BITMAPCOREHEADER_SIZE => BMPHeaderType::Core,
+            BITMAPINFOHEADER_SIZE => BMPHeaderType::Info,
+            BITMAPV2HEADER_SIZE => BMPHeaderType::V2,
+            BITMAPV3HEADER_SIZE => BMPHeaderType::V3,
+            BITMAPV4HEADER_SIZE => BMPHeaderType::V4,
+            BITMAPV5HEADER_SIZE => BMPHeaderType::V5,
+            _ if bmp_header_size < BITMAPCOREHEADER_SIZE => {
+                // Size of any valid header types won't be smaller than core header type.
+                return Err(DecoderError::HeaderTooSmall(bmp_header_size).into());
+            }
+            // OS/2 BITMAPCOREHEADER2 (OS22XBITMAPHEADER): 16-64 bytes, 4-byte aligned
+            // (plus special sizes 42 and 46). Sizes 40/52/56 are caught by exact arms
+            // above and decoded as Windows headers (layout-compatible, so this is fine;
+            // the only difference is that a 40-byte OS/2 header with RLE24 won't trigger
+            // the Os2V2 path, but that combination is effectively nonexistent).
+            _ if (OS2_V2_MIN_HEADER_SIZE..=OS2_V2_MAX_HEADER_SIZE).contains(&bmp_header_size)
+                && (bmp_header_size % 4 == 0 || bmp_header_size == 42 || bmp_header_size == 46) =>
+            {
+                BMPHeaderType::Os2V2
+            }
+            _ => {
+                return Err(ImageError::Unsupported(
+                    UnsupportedError::from_format_and_kind(
+                        ImageFormat::Bmp.into(),
+                        UnsupportedErrorKind::GenericFeature(format!(
+                            "Unknown bitmap header type (size={bmp_header_size})"
+                        )),
+                    ),
+                ))
+            }
+        };
+
+        let bitfield_compression = match self.bmp_header_type {
+            BMPHeaderType::Core => {
+                self.read_bitmap_core_header()?;
+                BitfieldCompression::Rgb
+            }
+            BMPHeaderType::Os2V2 => {
+                self.read_bitmap_os2v2_header(bmp_header_size)?;
+                BitfieldCompression::Rgb
+            }
+            BMPHeaderType::Info
+            | BMPHeaderType::V2
+            | BMPHeaderType::V3
+            | BMPHeaderType::V4
+            | BMPHeaderType::V5 => self.read_bitmap_info_header()?,
+        };
+
+        let mut bitmask_bytes_offset = 0;
+        if matches!(
+            self.image_type,
+            ImageType::Bitfields16 | ImageType::Bitfields32
+        ) {
+            self.read_bitmasks(bitfield_compression)?;
+
+            // Per https://learn.microsoft.com/en-us/windows/win32/gdi/bitmap-header-types, bitmaps
+            // using the `BITMAPINFOHEADER`, `BITMAPV4HEADER`, or `BITMAPV5HEADER` structures with
+            // an image type of `BI_BITFIELD` or `BI_ALPHABITFIELDS` contain bitfield masks
+            // immediately after the header.
+            //
+            // `read_bitmasks` correctly reads these from earlier in the header itself but we must
+            // ensure the reader starts on the image data itself, not these extra mask bytes.
+            if matches!(
+                self.bmp_header_type,
+                BMPHeaderType::Info | BMPHeaderType::V4 | BMPHeaderType::V5
+            ) {
+                bitmask_bytes_offset = match bitfield_compression {
+                    BitfieldCompression::Rgba => 16, // 4 masks * 4 bytes
+                    BitfieldCompression::Rgb => 12,  // 3 masks * 4 bytes
+                };
+            }
+        };
+
+        // Parse ICC profile metadata from V5 header (but don't read the profile data yet)
+        let mut icc_profile = None;
+        if bmp_header_size >= BITMAPV5HEADER_SIZE {
+            // Read the full V5 header into a buffer for ICC profile metadata parsing
+            // V5 header is 124 bytes total, minus 4-byte size field = 120 bytes
+            let mut header_buffer = vec![0u8; (bmp_header_size - 4) as usize];
+            let current_pos = self.reader.stream_position()?;
+            self.reader.seek(SeekFrom::Start(bmp_header_offset + 4))?;
+            self.reader.read_exact(&mut header_buffer)?;
+
+            // Extract ICC profile metadata for later reading
+            icc_profile = ParsedIccProfile::parse(&header_buffer, bmp_header_offset);
+
+            // Seek back to where we were
+            self.reader.seek(SeekFrom::Start(current_pos))?;
+        }
+
+        // Calculate palette offset (position after headers)
+        let palette_offset = bmp_header_end + bitmask_bytes_offset;
+
+        Ok(HeaderOffsets {
+            palette_offset,
+            icc_profile,
+        })
+    }
+
     #[cfg(feature = "ico")]
     #[doc(hidden)]
     pub fn read_metadata_in_ico_format(&mut self) -> ImageResult<()> {
@@ -877,14 +1516,17 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
         match self.colors_used {
             0 => Ok(1 << self.bit_count),
             _ => {
-                if self.colors_used > 1 << self.bit_count {
+                if self.spec_strictness == BmpSpec::Strict && self.colors_used > 1 << self.bit_count
+                {
                     return Err(DecoderError::PaletteSizeExceeded {
                         colors_used: self.colors_used,
                         bit_count: self.bit_count,
                     }
                     .into());
                 }
-                Ok(self.colors_used as usize)
+                // In lenient mode, clamp to max palette size for the bit depth
+                let max_size = 1usize << self.bit_count;
+                Ok((self.colors_used as usize).min(max_size))
             }
         }
     }
@@ -971,22 +1613,32 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
         let mut indices = vec![0; row_byte_length];
         let palette = self.palette.as_ref().unwrap();
         let bit_count = self.bit_count;
-        let reader = &mut self.reader;
         let width = self.width as usize;
         let skip_palette = self.indexed_color;
 
-        reader.seek(SeekFrom::Start(self.data_offset))?;
+        let rows_decoded = self.rows_decoded();
+        let start_row = rows_decoded.rows();
+        let top_down = matches!(rows_decoded, RowsDecoded::TopDown { .. });
+
+        let file_offset = self.data_offset + (start_row as u64 * row_byte_length as u64);
+        self.reader.seek(SeekFrom::Start(file_offset))?;
 
-        if num_channels == 4 {
-            buf.chunks_exact_mut(4).for_each(|c| c[3] = 0xFF);
+        // Set alpha to opaque for all pixels if needed (only on first call)
+        if start_row == 0 && num_channels == 4 {
+            buf.as_chunks_mut::<4>()
+                .0
+                .iter_mut()
+                .for_each(|c| c[3] = ALPHA_OPAQUE);
         }
 
-        with_rows(
+        let reader = &mut self.reader;
+        let result = with_rows_resumable(
             buf,
             self.width,
             self.height,
             num_channels,
-            self.top_down,
+            top_down,
+            start_row,
             |row| {
                 reader.read_exact(&mut indices)?;
                 if skip_palette {
@@ -1011,9 +1663,9 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
                 }
                 Ok(())
             },
-        )?;
+        );
 
-        Ok(())
+        self.finish_row_decode(result)
     }
 
     fn read_16_bit_pixel_data(
@@ -1022,79 +1674,106 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
         bitfields: Option<&Bitfields>,
     ) -> ImageResult<()> {
         let num_channels = self.num_channels();
-        let row_padding_len = self.width as usize % 2 * 2;
-        let row_padding = &mut [0; 2][..row_padding_len];
         let bitfields = match bitfields {
             Some(b) => b,
             None => self.bitfields.as_ref().unwrap(),
         };
-        let reader = &mut self.reader;
 
-        reader.seek(SeekFrom::Start(self.data_offset))?;
+        let row_data_len = self.width as usize * 2;
+        let row_padding_len = calculate_row_padding(row_data_len);
+        let total_row_len = row_data_len + row_padding_len;
+
+        let rows_decoded = self.rows_decoded();
+        let start_row = rows_decoded.rows();
+        let top_down = matches!(rows_decoded, RowsDecoded::TopDown { .. });
+        let width = self.width;
+        let height = self.height;
+
+        let file_offset = self.data_offset + (start_row as u64 * total_row_len as u64);
+        self.reader.seek(SeekFrom::Start(file_offset))?;
 
-        with_rows(
+        let mut row_buffer = allocate_row_buffer(total_row_len)?;
+
+        let reader = &mut self.reader;
+        let result = with_rows_resumable(
             buf,
-            self.width,
-            self.height,
+            width,
+            height,
             num_channels,
-            self.top_down,
+            top_down,
+            start_row,
             |row| {
-                for pixel in row.chunks_mut(num_channels) {
-                    let data = u32::from(reader.read_u16::<LittleEndian>()?);
-
+                reader.read_exact(&mut row_buffer)?;
+                let row_buffer_chunks = row_buffer.as_chunks::<2>().0.iter();
+                for (&row_data, pixel) in row_buffer_chunks.zip(row.chunks_exact_mut(num_channels))
+                {
+                    let data = u32::from(u16::from_le_bytes(row_data));
                     pixel[0] = bitfields.r.read(data);
                     pixel[1] = bitfields.g.read(data);
                     pixel[2] = bitfields.b.read(data);
                     if num_channels == 4 {
-                        if bitfields.a.len != 0 {
-                            pixel[3] = bitfields.a.read(data);
+                        pixel[3] = if bitfields.a.len != 0 {
+                            bitfields.a.read(data)
                         } else {
-                            pixel[3] = 0xFF;
-                        }
+                            ALPHA_OPAQUE
+                        };
                     }
                 }
-                reader.read_exact(row_padding)
+                Ok(())
             },
-        )?;
+        );
 
-        Ok(())
+        self.finish_row_decode(result)
     }
 
     /// Read image data from a reader in 32-bit formats that use bitfields.
     fn read_32_bit_pixel_data(&mut self, buf: &mut [u8]) -> ImageResult<()> {
         let num_channels = self.num_channels();
-
         let bitfields = self.bitfields.as_ref().unwrap();
 
-        let reader = &mut self.reader;
-        reader.seek(SeekFrom::Start(self.data_offset))?;
+        let row_data_len = self.width as usize * 4;
+
+        let rows_decoded = self.rows_decoded();
+        let start_row = rows_decoded.rows();
+        let top_down = matches!(rows_decoded, RowsDecoded::TopDown { .. });
+        let width = self.width;
+        let height = self.height;
 
-        with_rows(
+        let file_offset = self.data_offset + (start_row as u64 * row_data_len as u64);
+        self.reader.seek(SeekFrom::Start(file_offset))?;
+
+        let mut row_buffer = allocate_row_buffer(row_data_len)?;
+
+        let reader = &mut self.reader;
+        let result = with_rows_resumable(
             buf,
-            self.width,
-            self.height,
+            width,
+            height,
             num_channels,
-            self.top_down,
+            top_down,
+            start_row,
             |row| {
-                for pixel in row.chunks_mut(num_channels) {
-                    let data = reader.read_u32::<LittleEndian>()?;
-
+                reader.read_exact(&mut row_buffer)?;
+                let row_buffer_chunks = row_buffer.as_chunks::<4>().0.iter();
+                for (&row_data, pixel) in row_buffer_chunks.zip(row.chunks_exact_mut(num_channels))
+                {
+                    let data = u32::from_le_bytes(row_data);
                     pixel[0] = bitfields.r.read(data);
                     pixel[1] = bitfields.g.read(data);
                     pixel[2] = bitfields.b.read(data);
                     if num_channels == 4 {
-                        if bitfields.a.len != 0 {
-                            pixel[3] = bitfields.a.read(data);
+                        pixel[3] = if bitfields.a.len != 0 {
+                            bitfields.a.read(data)
                         } else {
-                            pixel[3] = 0xff;
-                        }
+                            ALPHA_OPAQUE
+                        };
                     }
                 }
                 Ok(())
             },
-        )?;
+        );
 
-        Ok(())
+        self.finish_row_decode(result)
     }
 
     /// Read image data from a reader where the colours are stored as 8-bit values (24 or 32-bit).
@@ -1104,220 +1783,345 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
         format: &FormatFullBytes,
     ) -> ImageResult<()> {
         let num_channels = self.num_channels();
+        let row_data_len = match *format {
+            FormatFullBytes::RGB24 => self.width as usize * 3,
+            FormatFullBytes::Format888 => self.width as usize * 4,
+            FormatFullBytes::RGB32 | FormatFullBytes::RGBA32 => self.width as usize * 4,
+        };
         let row_padding_len = match *format {
-            FormatFullBytes::RGB24 => (4 - (self.width as usize * 3) % 4) % 4,
+            FormatFullBytes::RGB24 => calculate_row_padding(row_data_len),
             _ => 0,
         };
-        let row_padding = &mut [0; 4][..row_padding_len];
+        let total_row_len = row_data_len + row_padding_len;
 
-        self.reader.seek(SeekFrom::Start(self.data_offset))?;
+        let rows_decoded = self.rows_decoded();
+        let start_row = rows_decoded.rows();
+        let top_down = matches!(rows_decoded, RowsDecoded::TopDown { .. });
+        let width = self.width;
+        let height = self.height;
 
-        let reader = &mut self.reader;
+        let file_offset = self.data_offset + (start_row as u64 * total_row_len as u64);
+        self.reader.seek(SeekFrom::Start(file_offset))?;
+
+        let mut row_buffer = allocate_row_buffer(total_row_len)?;
 
-        with_rows(
+        let reader = &mut self.reader;
+        let result = with_rows_resumable(
             buf,
-            self.width,
-            self.height,
+            width,
+            height,
             num_channels,
-            self.top_down,
+            top_down,
+            start_row,
             |row| {
-                for pixel in row.chunks_mut(num_channels) {
-                    if *format == FormatFullBytes::Format888 {
-                        reader.read_u8()?;
-                    }
+                reader.read_exact(&mut row_buffer)?;
 
-                    // Read the colour values (b, g, r).
-                    // Reading 3 bytes and reversing them is significantly faster than reading one
-                    // at a time.
-                    reader.read_exact(&mut pixel[0..3])?;
-                    pixel[0..3].reverse();
+                for (i, pixel) in row.chunks_mut(num_channels).enumerate() {
+                    let offset = match *format {
+                        FormatFullBytes::Format888 => i * 4 + 1, // Skip first byte
+                        _ => {
+                            i * match *format {
+                                FormatFullBytes::RGB24 => 3,
+                                _ => 4,
+                            }
+                        }
+                    };
 
-                    if *format == FormatFullBytes::RGB32 {
-                        reader.read_u8()?;
-                    }
+                    // Read the colour values (b, g, r) and reverse to (r, g, b)
+                    pixel[0..3].copy_from_slice(&row_buffer[offset..offset + 3]);
+                    pixel[0..3].reverse();
 
                     // Read the alpha channel if present
                     if *format == FormatFullBytes::RGBA32 {
-                        reader.read_exact(&mut pixel[3..4])?;
+                        pixel[3] = row_buffer[offset + 3];
                     } else if num_channels == 4 {
-                        pixel[3] = 0xFF;
+                        pixel[3] = ALPHA_OPAQUE;
                     }
                 }
-                reader.read_exact(row_padding)
+                Ok(())
             },
-        )?;
+        );
 
-        Ok(())
+        self.finish_row_decode(result)
     }
 
     fn read_rle_data(&mut self, buf: &mut [u8], image_type: ImageType) -> ImageResult<()> {
-        // Seek to the start of the actual image data.
-        self.reader.seek(SeekFrom::Start(self.data_offset))?;
+        let (start_row, start_x, start_pos) = match self.state {
+            DecoderState::ReadingRleData {
+                progress: RleProgress::NotStarted,
+            } => (0u32, 0u32, self.data_offset),
+            DecoderState::ReadingRleData {
+                progress: RleProgress::Checkpoint { row, x, stream_pos },
+            } => (row, x, stream_pos),
+            _ => unreachable!("read_rle_data called in unexpected state: {:?}", self.state),
+        };
+
+        self.reader.seek(SeekFrom::Start(start_pos))?;
 
         let num_channels = self.num_channels();
-        let p = self.palette.as_ref().unwrap();
+        let p = if image_type != ImageType::RLE24 {
+            Some(self.palette.as_ref().unwrap())
+        } else {
+            None
+        };
 
-        // Handling deltas in the RLE scheme means that we need to manually
-        // iterate through rows and pixels.  Even if we didn't have to handle
-        // deltas, we have to ensure that a single runlength doesn't straddle
-        // two rows.
-        let mut row_iter = self.rows(buf);
+        let mut row_iter = self.rows(buf).skip(start_row as usize);
+        let mut current_row = start_row;
+        let mut first_row_iteration = true;
+
+        // Pre-allocate buffer for RLE4/8 absolute mode (max 256 bytes).
+        // RLE24 reads inline BGR triples directly, so this buffer is unused.
+        let mut rle_indices_buffer = [0u8; 256];
+
+        let mut rle_reader = RleReader::new(&mut self.reader);
 
         while let Some(row) = row_iter.next() {
             let mut pixel_iter = row.chunks_exact_mut(num_channels);
 
-            let mut x = 0;
+            // When resuming mid-row, skip to the saved x position on the first row.
+            let mut x = if first_row_iteration && start_x > 0 {
+                pixel_iter.nth(start_x as usize - 1); // nth(n) consumes n+1 elements
+                start_x
+            } else {
+                0
+            };
+            first_row_iteration = false;
+
             loop {
-                let instruction = {
-                    let control_byte = self.reader.read_u8()?;
-                    match control_byte {
-                        RLE_ESCAPE => {
-                            let op = self.reader.read_u8()?;
-
-                            match op {
-                                RLE_ESCAPE_EOL => RLEInsn::EndOfRow,
-                                RLE_ESCAPE_EOF => RLEInsn::EndOfFile,
-                                RLE_ESCAPE_DELTA => {
-                                    let xdelta = self.reader.read_u8()?;
-                                    let ydelta = self.reader.read_u8()?;
-                                    RLEInsn::Delta(xdelta, ydelta)
-                                }
-                                _ => {
-                                    let mut length = op as usize;
-                                    if self.image_type == ImageType::RLE4 {
-                                        length = length.div_ceil(2);
-                                    }
-                                    length += length & 1;
-                                    let mut buffer = Vec::new();
-                                    self.reader.read_exact_vec(&mut buffer, length)?;
-                                    RLEInsn::Absolute(op, buffer)
-                                }
+                let control_byte = rle_reader.read_byte()?;
+
+                match control_byte {
+                    RLE_ESCAPE => {
+                        let op = rle_reader.read_byte()?;
+                        match op {
+                            RLE_ESCAPE_EOL => {
+                                pixel_iter.for_each(|p| p.fill(0));
+                                current_row += 1;
+                                x = 0;
+                                break;
                             }
-                        }
-                        _ => {
-                            let palette_index = self.reader.read_u8()?;
-                            RLEInsn::PixelRun(control_byte, palette_index)
-                        }
-                    }
-                };
-
-                match instruction {
-                    RLEInsn::EndOfFile => {
-                        pixel_iter.for_each(|p| p.fill(0));
-                        row_iter.for_each(|r| r.fill(0));
-                        return Ok(());
-                    }
-                    RLEInsn::EndOfRow => {
-                        pixel_iter.for_each(|p| p.fill(0));
-                        break;
-                    }
-                    RLEInsn::Delta(x_delta, y_delta) => {
-                        // The msdn site on bitmap compression doesn't specify
-                        // what happens to the values skipped when encountering
-                        // a delta code, however IE and the windows image
-                        // preview seems to replace them with black pixels,
-                        // so we stick to that.
-
-                        if y_delta > 0 {
-                            // Zero out the remainder of the current row.
-                            pixel_iter.for_each(|p| p.fill(0));
-
-                            // If any full rows are skipped, zero them out.
-                            for _ in 1..y_delta {
-                                let row = row_iter.next().ok_or(DecoderError::CorruptRleData)?;
-                                row.fill(0);
+                            RLE_ESCAPE_EOF => {
+                                pixel_iter.for_each(|p| p.fill(0));
+                                row_iter.for_each(|r| r.fill(0));
+                                return Ok(());
                             }
+                            RLE_ESCAPE_DELTA => {
+                                let x_delta = rle_reader.read_byte()?;
+                                let y_delta = rle_reader.read_byte()?;
+
+                                // IE and Windows image preview replace skipped pixels
+                                // with black, so we stick to that.
+                                if y_delta > 0 {
+                                    pixel_iter.for_each(|p| p.fill(0));
+
+                                    for _ in 1..y_delta {
+                                        let row =
+                                            row_iter.next().ok_or(DecoderError::CorruptRleData)?;
+                                        row.fill(0);
+                                    }
 
-                            // Set the pixel iterator to the start of the next row.
-                            pixel_iter = row_iter
-                                .next()
-                                .ok_or(DecoderError::CorruptRleData)?
-                                .chunks_exact_mut(num_channels);
-
-                            // Zero out the pixels up to the current point in the row.
-                            for _ in 0..x {
-                                pixel_iter
-                                    .next()
-                                    .ok_or(DecoderError::CorruptRleData)?
-                                    .fill(0);
-                            }
-                        }
+                                    current_row += y_delta as u32;
 
-                        for _ in 0..x_delta {
-                            let pixel = pixel_iter.next().ok_or(DecoderError::CorruptRleData)?;
-                            pixel.fill(0);
-                        }
-                        x += x_delta as usize;
-                    }
-                    RLEInsn::Absolute(length, indices) => {
-                        // Absolute mode cannot span rows, so if we run
-                        // out of pixels to process, we should stop
-                        // processing the image.
-                        match image_type {
-                            ImageType::RLE8 => {
-                                if !set_8bit_pixel_run(
-                                    &mut pixel_iter,
-                                    p,
-                                    indices.iter(),
-                                    length as usize,
-                                ) {
-                                    return Err(DecoderError::CorruptRleData.into());
+                                    pixel_iter = row_iter
+                                        .next()
+                                        .ok_or(DecoderError::CorruptRleData)?
+                                        .chunks_exact_mut(num_channels);
+
+                                    for _ in 0..x {
+                                        pixel_iter
+                                            .next()
+                                            .ok_or(DecoderError::CorruptRleData)?
+                                            .fill(0);
+                                    }
+                                }
+
+                                for _ in 0..x_delta {
+                                    let pixel =
+                                        pixel_iter.next().ok_or(DecoderError::CorruptRleData)?;
+                                    pixel.fill(0);
                                 }
+                                x += x_delta as u32;
                             }
-                            ImageType::RLE4 => {
-                                if !set_4bit_pixel_run(
-                                    &mut pixel_iter,
-                                    p,
-                                    indices.iter(),
-                                    length as usize,
-                                ) {
-                                    return Err(DecoderError::CorruptRleData.into());
+                            _ => {
+                                // Absolute mode: pixel data differs by RLE type.
+                                let count = op as usize;
+                                match image_type {
+                                    ImageType::RLE8 => {
+                                        let mut length = count;
+                                        length += length & 1;
+                                        rle_reader.read_exact(&mut rle_indices_buffer[..length])?;
+                                        if !set_8bit_pixel_run(
+                                            &mut pixel_iter,
+                                            p.unwrap(),
+                                            rle_indices_buffer[..length].iter(),
+                                            count,
+                                        ) {
+                                            return Err(DecoderError::CorruptRleData.into());
+                                        }
+                                    }
+                                    ImageType::RLE4 => {
+                                        let mut length = count.div_ceil(2);
+                                        length += length & 1;
+                                        rle_reader.read_exact(&mut rle_indices_buffer[..length])?;
+                                        if !set_4bit_pixel_run(
+                                            &mut pixel_iter,
+                                            p.unwrap(),
+                                            rle_indices_buffer[..length].iter(),
+                                            count,
+                                        ) {
+                                            return Err(DecoderError::CorruptRleData.into());
+                                        }
+                                    }
+                                    ImageType::RLE24 => {
+                                        for _ in 0..count {
+                                            let b = rle_reader.read_byte()?;
+                                            let g = rle_reader.read_byte()?;
+                                            let r = rle_reader.read_byte()?;
+                                            if let Some(pixel) = pixel_iter.next() {
+                                                pixel[0] = r;
+                                                pixel[1] = g;
+                                                pixel[2] = b;
+                                            }
+                                        }
+                                        // RLE24 absolute mode pads to word (2-byte) boundary.
+                                        if !(count * 3).is_multiple_of(2) {
+                                            rle_reader.read_byte()?;
+                                        }
+                                    }
+                                    _ => unreachable!(),
                                 }
+                                x += count as u32;
                             }
-                            _ => unreachable!(),
                         }
-                        x += length as usize;
                     }
-                    RLEInsn::PixelRun(n_pixels, palette_index) => {
+                    _ => {
+                        // Encoded run: pixel data differs by RLE type.
+                        let n_pixels = control_byte as usize;
                         match image_type {
                             ImageType::RLE8 => {
-                                // A pixel run isn't allowed to span rows.
-                                // imagemagick produces invalid images where n_pixels exceeds row length,
-                                // so we clamp n_pixels to the row length to display them properly:
+                                // Clamp to row length for compat with imagemagick:
                                 // https://github.com/image-rs/image/issues/2321
-                                //
-                                // This is like set_8bit_pixel_run() but doesn't fail when `n_pixels` is too large
-                                let repeat_pixel: [u8; 3] = p[palette_index as usize];
-                                (&mut pixel_iter).take(n_pixels as usize).for_each(|p| {
-                                    p[2] = repeat_pixel[2];
-                                    p[1] = repeat_pixel[1];
+                                let palette_index = rle_reader.read_byte()?;
+                                let repeat_pixel: [u8; 3] = p.unwrap()[palette_index as usize];
+                                (&mut pixel_iter).take(n_pixels).for_each(|p| {
                                     p[0] = repeat_pixel[0];
+                                    p[1] = repeat_pixel[1];
+                                    p[2] = repeat_pixel[2];
                                 });
                             }
                             ImageType::RLE4 => {
+                                let palette_index = rle_reader.read_byte()?;
                                 if !set_4bit_pixel_run(
                                     &mut pixel_iter,
-                                    p,
+                                    p.unwrap(),
                                     repeat(&palette_index),
-                                    n_pixels as usize,
+                                    n_pixels,
                                 ) {
                                     return Err(DecoderError::CorruptRleData.into());
                                 }
                             }
+                            ImageType::RLE24 => {
+                                let b = rle_reader.read_byte()?;
+                                let g = rle_reader.read_byte()?;
+                                let r = rle_reader.read_byte()?;
+                                for _ in 0..n_pixels {
+                                    if let Some(pixel) = pixel_iter.next() {
+                                        pixel[0] = r;
+                                        pixel[1] = g;
+                                        pixel[2] = b;
+                                    }
+                                }
+                            }
                             _ => unreachable!(),
                         }
-                        x += n_pixels as usize;
+                        x += n_pixels as u32;
                     }
                 }
+
+                // Checkpoint after every instruction to avoid potential quadratic
+                // time complexity when the decoder is given data one byte at a time.
+                self.state = DecoderState::ReadingRleData {
+                    progress: RleProgress::Checkpoint {
+                        row: current_row,
+                        x,
+                        stream_pos: start_pos + rle_reader.bytes_read(),
+                    },
+                };
             }
+
+            // Checkpoint after EndOfRow (which breaks out of the inner loop).
+            self.state = DecoderState::ReadingRleData {
+                progress: RleProgress::Checkpoint {
+                    row: current_row,
+                    x,
+                    stream_pos: start_pos + rle_reader.bytes_read(),
+                },
+            };
         }
 
         Ok(())
     }
 
-    /// Read the actual data of the image. This function is deliberately not public because it
-    /// cannot be called multiple times without seeking back the underlying reader in between.
-    pub(crate) fn read_image_data(&mut self, buf: &mut [u8]) -> ImageResult<()> {
+    /// Determine if the current image type is RLE-compressed.
+    fn is_rle(&self) -> bool {
+        matches!(
+            self.image_type,
+            ImageType::RLE4 | ImageType::RLE8 | ImageType::RLE24
+        )
+    }
+
+    /// Returns which rows in the output buffer contain valid decoded pixel data.
+    ///
+    /// See [`RowsDecoded`] for details on how to interpret the result.
+    pub fn rows_decoded(&self) -> RowsDecoded {
+        let rows = match self.state {
+            DecoderState::ReadingRowData { rows_decoded } => rows_decoded,
+            DecoderState::ReadingRleData { progress } => match progress {
+                RleProgress::NotStarted => 0,
+                // row is 0-indexed current row; rows 0..row are complete
+                RleProgress::Checkpoint { row, .. } => row,
+            },
+            DecoderState::ImageDecoded => self.height as u32,
+            DecoderState::ReadingMetadata { .. } => 0,
+        };
+        if self.top_down {
+            RowsDecoded::TopDown { rows }
+        } else {
+            RowsDecoded::BottomUp { rows }
+        }
+    }
+
+    /// Handle the result of a row-based decode operation, updating state accordingly.
+    fn finish_row_decode(&mut self, result: Result<u32, (u32, io::Error)>) -> ImageResult<()> {
+        let (Ok(rows) | Err((rows, _))) = result;
+        self.state = DecoderState::ReadingRowData { rows_decoded: rows };
+        match result {
+            Ok(_) => Ok(()),
+            Err((_, e)) => Err(e)?,
+        }
+    }
+
+    /// Read the actual pixel data of the image.
+    ///
+    /// Must be called after `read_metadata()` succeeds. On `UnexpectedEof`, the decoder
+    /// can be retried:
+    ///
+    /// - For non-RLE formats: decoding resumes from the last successfully decoded row.
+    ///   Already-decoded rows are preserved in `buf`.
+    /// - For RLE formats: decoding resumes from the last checkpoint (completed instruction symbol).
+    ///   Rows and pixels completed before the error are preserved in `buf`.
+    pub fn read_image_data(&mut self, buf: &mut [u8]) -> ImageResult<()> {
+        match self.state {
+            DecoderState::ImageDecoded => Ok(()),
+            DecoderState::ReadingRowData { .. } | DecoderState::ReadingRleData { .. } => self
+                .read_image_data_impl(buf)
+                .map(|()| self.state = DecoderState::ImageDecoded),
+            DecoderState::ReadingMetadata { .. } => Err(DecoderError::MetadataNotRead.into()),
+        }
+    }
+
+    /// Internal implementation of image data reading.
+    fn read_image_data_impl(&mut self, buf: &mut [u8]) -> ImageResult<()> {
         match self.image_type {
             ImageType::Palette => self.read_palettized_pixel_data(buf),
             ImageType::RGB16 => self.read_16_bit_pixel_data(buf, Some(&R5_G5_B5_COLOR_MASK)),
@@ -1326,6 +2130,7 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
             ImageType::RGBA32 => self.read_full_byte_pixel_data(buf, &FormatFullBytes::RGBA32),
             ImageType::RLE8 => self.read_rle_data(buf, ImageType::RLE8),
             ImageType::RLE4 => self.read_rle_data(buf, ImageType::RLE4),
+            ImageType::RLE24 => self.read_rle_data(buf, ImageType::RLE24),
             ImageType::Bitfields16 => match self.bitfields {
                 Some(_) => self.read_16_bit_pixel_data(buf, None),
                 None => Err(DecoderError::BitfieldMasksMissing(16).into()),
@@ -1359,6 +2164,10 @@ impl<R: BufRead + Seek> ImageDecoder for BmpDecoder<R> {
         }
     }
 
+    fn icc_profile(&mut self) -> ImageResult<Option<Vec<u8>>> {
+        Ok(self.icc_profile.clone())
+    }
+
     fn read_image(mut self, buf: &mut [u8]) -> ImageResult<()> {
         assert_eq!(u64::try_from(buf.len()), Ok(self.total_bytes()));
         self.read_image_data(buf)
@@ -1369,37 +2178,9 @@ impl<R: BufRead + Seek> ImageDecoder for BmpDecoder<R> {
     }
 }
 
-impl<R: BufRead + Seek> ImageDecoderRect for BmpDecoder<R> {
-    fn read_rect(
-        &mut self,
-        x: u32,
-        y: u32,
-        width: u32,
-        height: u32,
-        buf: &mut [u8],
-        row_pitch: usize,
-    ) -> ImageResult<()> {
-        let start = self.reader.stream_position()?;
-        load_rect(
-            x,
-            y,
-            width,
-            height,
-            buf,
-            row_pitch,
-            self,
-            self.total_bytes() as usize,
-            |_, _| Ok(()),
-            |s, buf| s.read_image_data(buf),
-        )?;
-        self.reader.seek(SeekFrom::Start(start))?;
-        Ok(())
-    }
-}
-
 #[cfg(test)]
 mod test {
-    use std::io::{BufReader, Cursor};
+    use std::io::{BufRead, BufReader, Cursor, Seek};
 
     use super::*;
 
@@ -1418,16 +2199,6 @@ mod test {
         }
     }
 
-    #[test]
-    fn read_rect() {
-        let f =
-            BufReader::new(std::fs::File::open("tests/images/bmp/images/Core_8_Bit.bmp").unwrap());
-        let mut decoder = BmpDecoder::new(f).unwrap();
-
-        let mut buf: Vec<u8> = vec![0; 8 * 8 * 3];
-        decoder.read_rect(0, 0, 8, 8, &mut buf, 8 * 3).unwrap();
-    }
-
     #[test]
     fn read_rle_too_short() {
         let data = vec![
@@ -1477,4 +2248,474 @@ mod test {
             assert_eq!(ref_img, no_hdr_img);
         }
     }
+
+    /// Validates that the given ICC profile data can be parsed by moxcms and contains
+    /// the expected properties for an RGB display profile.
+    fn validate_icc_profile(
+        profile_data: &[u8],
+        source_file: &str,
+        expected_color_space: moxcms::DataColorSpace,
+        expected_profile_class: moxcms::ProfileClass,
+    ) {
+        let parsed_profile = moxcms::ColorProfile::new_from_slice(profile_data);
+        assert!(
+            parsed_profile.is_ok(),
+            "ICC profile from {} should be parseable by moxcms: {:?}",
+            source_file,
+            parsed_profile.err()
+        );
+        let parsed_profile = parsed_profile.unwrap();
+        assert_eq!(
+            parsed_profile.color_space, expected_color_space,
+            "ICC profile from {} should have RGB color space",
+            source_file
+        );
+        assert_eq!(
+            parsed_profile.profile_class, expected_profile_class,
+            "ICC profile from {} should be a display/monitor profile",
+            source_file
+        );
+    }
+
+    #[test]
+    fn test_icc_profile() {
+        // V5 header file without embedded ICC profile
+        let f =
+            BufReader::new(std::fs::File::open("tests/images/bmp/images/V5_24_Bit.bmp").unwrap());
+        let mut decoder = BmpDecoder::new(f).unwrap();
+        let profile = decoder.icc_profile().unwrap();
+        assert!(profile.is_none());
+
+        // Test files with embedded ICC profiles
+        let f =
+            BufReader::new(std::fs::File::open("tests/images/bmp/images/rgb24prof.bmp").unwrap());
+        let mut decoder = BmpDecoder::new(f).unwrap();
+        let profile = decoder.icc_profile().unwrap();
+        assert!(profile.is_some());
+        let profile_data = profile.unwrap();
+        assert_eq!(profile_data.len(), 3048);
+        validate_icc_profile(
+            &profile_data,
+            "rgb24prof.bmp",
+            moxcms::DataColorSpace::Rgb,
+            moxcms::ProfileClass::DisplayDevice,
+        );
+
+        let f =
+            BufReader::new(std::fs::File::open("tests/images/bmp/images/rgb24prof2.bmp").unwrap());
+        let mut decoder = BmpDecoder::new(f).unwrap();
+        let profile = decoder.icc_profile().unwrap();
+        assert!(profile.is_some());
+        let profile_data = profile.unwrap();
+        assert_eq!(profile_data.len(), 540);
+        validate_icc_profile(
+            &profile_data,
+            "rgb24prof2.bmp",
+            moxcms::DataColorSpace::Rgb,
+            moxcms::ProfileClass::DisplayDevice,
+        );
+    }
+
+    /// A reader that simulates partial data availability for testing resumable decoding.
+    /// It wraps a byte slice and limits how many bytes can be read before returning UnexpectedEof.
+    struct PartialReader {
+        data: Vec<u8>,
+        position: u64,
+        available_bytes: usize,
+    }
+
+    impl PartialReader {
+        fn new(data: Vec<u8>) -> Self {
+            Self {
+                data,
+                position: 0,
+                available_bytes: 0,
+            }
+        }
+
+        /// Set the number of bytes available for reading (absolute, not additive).
+        fn set_available(&mut self, bytes: usize) {
+            self.available_bytes = bytes.min(self.data.len());
+        }
+    }
+
+    impl io::Read for PartialReader {
+        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+            if self.position as usize >= self.available_bytes {
+                return Err(io::Error::new(
+                    io::ErrorKind::UnexpectedEof,
+                    "simulated partial data",
+                ));
+            }
+
+            let available = self.available_bytes - self.position as usize;
+            let to_read = buf.len().min(available);
+            let start = self.position as usize;
+            buf[..to_read].copy_from_slice(&self.data[start..start + to_read]);
+            self.position += to_read as u64;
+            Ok(to_read)
+        }
+    }
+
+    impl BufRead for PartialReader {
+        fn fill_buf(&mut self) -> io::Result<&[u8]> {
+            if self.position as usize >= self.available_bytes {
+                return Err(io::Error::new(
+                    io::ErrorKind::UnexpectedEof,
+                    "simulated partial data",
+                ));
+            }
+
+            let start = self.position as usize;
+            Ok(&self.data[start..self.available_bytes])
+        }
+
+        fn consume(&mut self, amt: usize) {
+            self.position += amt as u64;
+        }
+    }
+
+    impl Seek for PartialReader {
+        fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
+            let new_pos = match pos {
+                SeekFrom::Start(offset) => offset as i64,
+                SeekFrom::End(offset) => self.data.len() as i64 + offset,
+                SeekFrom::Current(offset) => self.position as i64 + offset,
+            };
+
+            if new_pos < 0 {
+                return Err(io::Error::new(
+                    io::ErrorKind::InvalidInput,
+                    "seek to negative position",
+                ));
+            }
+
+            self.position = new_pos as u64;
+            Ok(self.position)
+        }
+    }
+
+    /// Helper to check if an error is UnexpectedEof
+    fn is_unexpected_eof(err: &ImageError) -> bool {
+        matches!(err, ImageError::IoError(e) if e.kind() == io::ErrorKind::UnexpectedEof)
+    }
+
+    /// Test resumable decoding with various BMP formats.
+    /// Verifies that read_metadata() and read_image_data() can be retried after
+    /// UnexpectedEof and produce identical results to normal decoding.
+    /// Also verifies metadata phase progress and row-level progress for non-RLE formats.
+    #[test]
+    fn test_resumable_decoding() {
+        use crate::ImageDecoder;
+
+        struct TestCase {
+            path: &'static str,
+            is_rle: bool,
+            has_palette: bool,
+            has_icc_profile: bool,
+            top_down: bool,
+        }
+
+        // Test multiple BMP formats to ensure resumable decoding works across variants
+        let test_files = [
+            TestCase {
+                path: "tests/images/bmp/images/Info_R8_G8_B8.bmp",
+                is_rle: false,
+                has_palette: false,
+                has_icc_profile: false,
+                top_down: false,
+            },
+            TestCase {
+                path: "tests/images/bmp/images/Info_A8_R8_G8_B8.bmp",
+                is_rle: false,
+                has_palette: false,
+                has_icc_profile: false,
+                top_down: false,
+            },
+            TestCase {
+                path: "tests/images/bmp/images/Info_A8_R8_G8_B8_Top_Down.bmp",
+                is_rle: false,
+                has_palette: false,
+                has_icc_profile: false,
+                top_down: true,
+            },
+            TestCase {
+                path: "tests/images/bmp/images/Info_8_Bit.bmp",
+                is_rle: false,
+                has_palette: true,
+                has_icc_profile: false,
+                top_down: false,
+            },
+            TestCase {
+                path: "tests/images/bmp/images/Core_8_Bit.bmp",
+                is_rle: false,
+                has_palette: true,
+                has_icc_profile: false,
+                top_down: false,
+            },
+            TestCase {
+                path: "tests/images/bmp/images/pal8rle.bmp",
+                is_rle: true,
+                has_palette: true,
+                has_icc_profile: false,
+                top_down: false,
+            },
+            TestCase {
+                path: "tests/images/bmp/images/pal4rle.bmp",
+                is_rle: true,
+                has_palette: true,
+                has_icc_profile: false,
+                top_down: false,
+            },
+            TestCase {
+                path: "tests/images/bmp/images/rgb24prof.bmp",
+                is_rle: false,
+                has_palette: false,
+                has_icc_profile: true,
+                top_down: false,
+            },
+            TestCase {
+                path: "tests/images/bmp/images/rgb24rle24.bmp",
+                is_rle: true,
+                has_palette: false,
+                has_icc_profile: false,
+                top_down: false,
+            },
+        ];
+
+        for TestCase {
+            path,
+            is_rle,
+            has_palette,
+            has_icc_profile,
+            top_down,
+        } in test_files
+        {
+            let data = std::fs::read(path).unwrap();
+            let file_size = data.len();
+
+            // Get reference result from normal decoding
+            let mut ref_decoder = BmpDecoder::new(Cursor::new(data.clone())).unwrap();
+            let expected_bytes = ref_decoder.total_bytes() as usize;
+            let mut ref_buf = vec![0u8; expected_bytes];
+            let ref_icc_len = ref_decoder.icc_profile().unwrap().map(|p| p.len());
+            ref_decoder.read_image(&mut ref_buf).unwrap();
+
+            // Test resumable decoding with simulated streaming
+            let reader = PartialReader::new(data);
+            let mut decoder = BmpDecoder::new_resumable(reader);
+
+            // Track metadata phase transitions
+            let mut saw_reading_palette = false;
+            let mut saw_reading_icc = false;
+
+            // Phase 1: Stream bytes until metadata succeeds
+            let mut bytes_available = 0;
+            loop {
+                decoder.reader.set_available(bytes_available);
+                match decoder.read_metadata() {
+                    Ok(()) => break,
+                    Err(ref e) if is_unexpected_eof(e) => {
+                        if let DecoderState::ReadingMetadata { progress } = decoder.state {
+                            match progress {
+                                MetadataProgress::ReadingPalette { .. } => {
+                                    saw_reading_palette = true
+                                }
+                                MetadataProgress::ReadingIccProfile { .. } => {
+                                    saw_reading_icc = true
+                                }
+                                _ => {}
+                            }
+                        }
+
+                        // Simulate more data arriving (add 10 bytes at a time, capped at file size)
+                        bytes_available = (bytes_available + 10).min(file_size);
+                        assert!(
+                            bytes_available <= file_size,
+                            "{path}: metadata should succeed before EOF"
+                        );
+                    }
+                    Err(e) => panic!("{path}: unexpected error during metadata: {e:?}"),
+                }
+            }
+
+            // Verify metadata phase transitions occurred as expected
+            if has_palette {
+                assert!(
+                    saw_reading_palette,
+                    "{path}: should have seen ReadingPalette phase"
+                );
+            }
+            if has_icc_profile {
+                assert!(
+                    saw_reading_icc,
+                    "{path}: should have seen ReadingIccProfile phase"
+                );
+                let icc = decoder.icc_profile().unwrap();
+                assert_eq!(
+                    icc.map(|p| p.len()),
+                    ref_icc_len,
+                    "{path}: ICC profile length mismatch"
+                );
+            }
+
+            // Verify dimensions are available after metadata
+            let (width, height) = decoder.dimensions();
+            assert!(width > 0 && height > 0, "{path}: invalid dimensions");
+            assert_eq!(
+                decoder.total_bytes() as usize,
+                expected_bytes,
+                "{path}: total_bytes mismatch"
+            );
+
+            // Phase 2: Stream bytes until image data succeeds
+            let mut buf = vec![0u8; expected_bytes];
+            let mut prev_decoded_rows = 0u32;
+            loop {
+                decoder.reader.set_available(bytes_available);
+                match decoder.read_image_data(&mut buf) {
+                    Ok(()) => {
+                        // After successful decode, rows_decoded() should return full height
+                        let progress = decoder.rows_decoded();
+                        assert_eq!(
+                            progress.rows(),
+                            height,
+                            "{path}: rows_decoded() should equal height after complete decode"
+                        );
+                        if top_down {
+                            assert!(
+                                matches!(progress, RowsDecoded::TopDown { .. }),
+                                "{path}: top-down file should produce TopDown, got {progress:?}"
+                            );
+                        } else {
+                            assert!(
+                                matches!(progress, RowsDecoded::BottomUp { .. }),
+                                "{path}: bottom-up file should produce BottomUp, got {progress:?}"
+                            );
+                        }
+                        break;
+                    }
+                    Err(ref e) if is_unexpected_eof(e) => {
+                        // Validate rows_decoded() returns correct count and variant
+                        let progress = decoder.rows_decoded();
+                        let decoded_rows = progress.rows();
+                        assert!(
+                            decoded_rows <= height,
+                            "{path}: rows_decoded() {decoded_rows} exceeds height {height}"
+                        );
+                        assert!(decoded_rows >= prev_decoded_rows, "{path}: rows_decoded() decreased from {prev_decoded_rows} to {decoded_rows}");
+                        prev_decoded_rows = decoded_rows;
+
+                        // Verify state tracks progress appropriately
+                        match decoder.state {
+                            DecoderState::ReadingRowData { rows_decoded } => {
+                                assert!(!is_rle, "{path}: expected ReadingRleData for RLE format");
+                                assert!(
+                                    rows_decoded < height,
+                                    "{path}: rows_decoded {rows_decoded} >= height {height}"
+                                );
+                                assert_eq!(
+                                    decoded_rows, rows_decoded,
+                                    "{path}: rows_decoded() mismatch"
+                                );
+                            }
+                            DecoderState::ReadingRleData { progress } => {
+                                assert!(
+                                    is_rle,
+                                    "{path}: expected ReadingRowData for non-RLE format"
+                                );
+                                match progress {
+                                    RleProgress::NotStarted => {
+                                        assert_eq!(
+                                            decoded_rows, 0,
+                                            "{path}: should be 0 for NotStarted"
+                                        );
+                                    }
+                                    RleProgress::Checkpoint { row, .. } => {
+                                        assert!(
+                                            row < height,
+                                            "{path}: RLE row {row} >= height {height}"
+                                        );
+                                        assert_eq!(
+                                            decoded_rows, row,
+                                            "{path}: rows_decoded() mismatch with RLE row"
+                                        );
+                                    }
+                                }
+                            }
+                            _ => panic!("{path}: unexpected state: {:?}", decoder.state),
+                        }
+
+                        bytes_available += 100;
+                        assert!(
+                            bytes_available <= file_size + 100,
+                            "{path}: image data should succeed before EOF"
+                        );
+                    }
+                    Err(e) => panic!("{path}: unexpected error during image data: {e:?}"),
+                }
+            }
+
+            // Verify decoded data matches reference
+            assert_eq!(buf, ref_buf, "{path}: decoded data mismatch");
+        }
+    }
+
+    /// Test that BMP files with known spec violations are accepted by the
+    /// decoder (which defaults to lenient mode), and that strict mode still
+    /// detects the violations internally.
+    ///
+    /// These files come from the Chromium BMP test suite ("bad/" category):
+    /// - `rletopdown`: RLE compression with top-down orientation (spec forbids this)
+    /// - `badplanes`: planes field != 1 (spec requires exactly 1)
+    /// - `badpalettesize`: colors_used exceeds max for the bit depth
+    /// - `pal8oversizepal`: 8-bit palette with colors_used=300 (max is 256)
+    /// - `rgb16-880`: 16-bit bitfields with 8-8-0 channel widths (blue mask is zero)
+    #[test]
+    fn test_strict_vs_lenient_spec_validation() {
+        let questionable_files = [
+            (
+                "tests/images/bmp/images/lenient/rletopdown.bmp",
+                "rletopdown: RLE with top-down should be rejected in strict mode",
+            ),
+            (
+                "tests/images/bmp/images/lenient/badplanes.bmp",
+                "badplanes: planes != 1 should be rejected in strict mode",
+            ),
+            (
+                "tests/images/bmp/images/lenient/badpalettesize.bmp",
+                "badpalettesize: palette size exceeding bit depth should be rejected in strict mode",
+            ),
+            (
+                "tests/images/bmp/images/lenient/pal8oversizepal.bmp",
+                "pal8oversizepal: colors_used=300 exceeds max 256 for 8-bit",
+            ),
+            (
+                "tests/images/bmp/images/lenient/rgb16-880.bmp",
+                "rgb16-880: zero blue mask should be rejected in strict mode",
+            ),
+        ];
+
+        for (path, description) in &questionable_files {
+            let data = std::fs::read(path)
+                .unwrap_or_else(|e| panic!("{description}: failed to read {path}: {e}"));
+
+            // Default (lenient) mode: these files should be accepted
+            let decoder = BmpDecoder::new(Cursor::new(&data)).unwrap_or_else(|e| {
+                panic!("{description}: decoding failed: {e:?}");
+            });
+            let mut buf = vec![0u8; decoder.total_bytes() as usize];
+            decoder.read_image(buf.as_mut_slice()).unwrap_or_else(|e| {
+                panic!("{description}: read_image failed: {e:?}");
+            });
+
+            // Strict mode (internal): these files should be rejected
+            let mut strict_decoder = BmpDecoder::new_resumable(Cursor::new(&data));
+            strict_decoder.spec_strictness = BmpSpec::Strict;
+            assert!(
+                strict_decoder.read_metadata().is_err(),
+                "{description}: expected error in strict mode, but got Ok"
+            );
+        }
+    }
 }
 diff --git a/third_party/rust/chromium_crates_io/vendor/image-v0_25/src/codecs/bmp/mod.rs  b/third_party/rust/chromium_crates_io/vendor/image-v0_25/src/codecs/bmp/mod.rs
--- a/third_party/rust/chromium_crates_io/vendor/image-v0_25/src/codecs/bmp/mod.rs
+++ b/third_party/rust/chromium_crates_io/vendor/image-v0_25/src/codecs/bmp/mod.rs
@@ -7,6 +7,7 @@
 //!  * <https://en.wikipedia.org/wiki/BMP_file_format>
 
 pub use self::decoder::BmpDecoder;
+pub use self::decoder::RowsDecoded;
 pub use self::encoder::BmpEncoder;
 
 mod decoder;
-- 
2.34.1


