M4.68: BMP export at 1 to 32 bits, Windows or OS/2, with RLE and flipped rows

document.export {format: "bmp"} writes BMP through the raster export path:
depth 1 (black and white), 4 or 8 (a palette by reduction and dither,
optionally RLE4/RLE8), 16 (5-5-5), 24 or 32 (with alpha, a version 4
header); colorModel rgb or gray; fileFormat windows or os2 (a core header);
flipRows writes top-down rows (a negative height). Impossible combinations
are refused as bad params. Export As offers it with the bmpOptions dialog,
which keeps its choices writable together; BMP moves from read-only to read
and write (tests that used .bmp as the unwritable format now use .dwg).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 15:24:35 +07:00
co-authored by Claude Opus 5.5
parent d01b2fbd8a
commit f2fc8782c7
19 changed files with 668 additions and 44 deletions
+221
View File
@@ -0,0 +1,221 @@
//! BMP export: the Windows layout (a version 3 header; version 4 with an alpha mask at 32 bits)
//! or the OS/2 one (a core header); 1, 4 or 8 bits per pixel through a palette ([`quantize`]),
//! optionally run-length encoded (RLE4, RLE8), or 16 (5-5-5), 24 or 32 bits (with alpha); rows
//! bottom-up, or top-down (a negative height).
use super::on_white;
use super::png::{pack, pixels_per_metre};
use super::quantize::{self, PaletteOptions, Reduction};
/// Bits per pixel a BMP can have.
pub const DEPTHS: [u8; 6] = [1, 4, 8, 16, 24, 32];
/// Those of the OS/2 layout.
pub const OS2_DEPTHS: [u8; 4] = [1, 4, 8, 24];
/// The BMP options of a raster export.
#[derive(Clone, Debug, PartialEq)]
pub struct BmpOptions {
/// The OS/2 layout: 1, 4, 8 or 24 bits, uncompressed, rows bottom-up.
pub os2: bool,
/// Bits per pixel ([`DEPTHS`]).
pub depth: u8,
/// Run-length encode a 4- or 8-bit image.
pub rle: bool,
/// Rows top-down (flipped row order: a negative height) instead of bottom-up.
pub top_down: bool,
/// Greys instead of colours.
pub gray: bool,
}
impl Default for BmpOptions {
fn default() -> Self {
Self { os2: false, depth: 24, rle: false, top_down: false, gray: false }
}
}
impl BmpOptions {
/// Why these options can't be written together, if they can't.
pub fn check(&self) -> Result<(), String> {
let err = |s: &str| Err(s.to_string());
if !DEPTHS.contains(&self.depth) {
return Err(format!("BMP depth {}: 1, 4, 8, 16, 24 or 32 bits per pixel", self.depth));
}
if self.os2 && !OS2_DEPTHS.contains(&self.depth) {
return err("OS/2 bitmaps have 1, 4, 8 or 24 bits per pixel");
}
if self.rle && (self.os2 || !matches!(self.depth, 4 | 8)) {
return err("RLE compression is for 4- and 8-bit Windows bitmaps");
}
if self.top_down && (self.rle || self.os2) {
return err("compressed and OS/2 bitmaps store their rows bottom-up: turn off flipped rows");
}
Ok(())
}
/// The colours of a 1-, 4- or 8-bit image: black and white at 1 bit, greys, or `palette`.
fn palette(&self, palette: &PaletteOptions) -> PaletteOptions {
let reduction = match self.depth {
1 => Reduction::BlackWhite,
_ if self.gray => Reduction::Gray,
_ => palette.reduction,
};
PaletteOptions { colors: 1 << self.depth, reduction, transparency: false, matte: Some([255; 3]), ..palette.clone() }
}
}
/// Straight-alpha RGBA pixels as a BMP. At 1, 4 and 8 bits `palette` picks the colours (its
/// reduction and dither); every depth but 32 bits blends partly transparent pixels over white.
pub fn encode(rgba: &[u8], width: u32, height: u32, ppi: f64, o: &BmpOptions, palette: &PaletteOptions) -> Result<Vec<u8>, String> {
o.check()?;
let (w, h) = (width as usize, height as usize);
let side = if o.os2 { u32::from(u16::MAX) } else { i32::MAX as u32 };
if w == 0 || h == 0 || width > side || height > side || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) {
return Err(format!("BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer"));
}
let px = rgba.as_chunks::<4>().0;
let gray = |c: [u8; 3]| if o.gray { [quantize::luma(c); 3] } else { c };
// Bottom-up unless flipped.
let row_at = |y: usize| if o.top_down { y } else { h - 1 - y };
// Rows are padded to 4 bytes.
let mut data = Vec::with_capacity((w * usize::from(o.depth)).div_ceil(32) * 4 * h);
let mut colors = vec![];
if o.depth <= 8 {
let ix = quantize::quantize(rgba, width, height, &o.palette(palette));
for y in 0..h {
let row = ix.indices.get(row_at(y) * w..).and_then(|r| r.get(..w)).unwrap_or_default();
if o.rle {
rle_row(row, o.depth, &mut data);
// End of line, or of the bitmap after the last.
data.extend([0, u8::from(y + 1 == h)]);
} else {
data.extend_from_slice(&pack(row, width, 1, o.depth));
data.resize(data.len().next_multiple_of(4), 0);
}
}
colors = ix.palette;
} else {
for y in 0..h {
for p in px.get(row_at(y) * w..).and_then(|r| r.get(..w)).unwrap_or_default() {
match o.depth {
16 => {
let [r, g, b] = gray(on_white(p)).map(|v| u16::from(v >> 3));
data.extend((r << 10 | g << 5 | b).to_le_bytes());
}
24 => {
let [r, g, b] = gray(on_white(p));
data.extend([b, g, r]);
}
_ => {
let [r, g, b] = gray([p[0], p[1], p[2]]);
data.extend([b, g, r, p[3]]);
}
}
}
data.resize(data.len().next_multiple_of(4), 0);
}
}
header(&data, width, height, ppi, o, &colors)
}
/// The file: file header, info header, palette, then the pixel data.
fn header(data: &[u8], width: u32, height: u32, ppi: f64, o: &BmpOptions, colors: &[[u8; 3]]) -> Result<Vec<u8>, String> {
let too_large = || "the image is too large for BMP: lower the resolution".to_string();
let indexed = o.depth <= 8;
let (info_len, entry) = match (o.os2, o.depth) {
(true, _) => (12, 3),
(false, 32) => (108, 4),
(false, _) => (40, 4),
};
// OS/2 palettes have an entry for every index.
let entries = match (indexed, o.os2) {
(false, _) => 0,
(true, true) => 1 << o.depth,
(true, false) => colors.len(),
};
let offset = 14 + info_len + entries * entry;
let size = u32::try_from(offset + data.len()).map_err(|_| too_large())?;
let data_len = u32::try_from(data.len()).map_err(|_| too_large())?;
let mut out = Vec::with_capacity(offset + data.len());
out.extend(b"BM");
out.extend(size.to_le_bytes());
out.extend([0; 4]);
out.extend((offset as u32).to_le_bytes());
if o.os2 {
out.extend(12u32.to_le_bytes());
// Sides checked against u16 by `encode`.
out.extend((width as u16).to_le_bytes());
out.extend((height as u16).to_le_bytes());
out.extend(1u16.to_le_bytes());
out.extend(u16::from(o.depth).to_le_bytes());
} else {
let compression: u32 = match (o.rle, o.depth) {
(true, 8) => 1,
(true, _) => 2,
(false, 32) => 3,
(false, _) => 0,
};
let ppm = pixels_per_metre(ppi).min(i32::MAX as u32);
let h = if o.top_down { -(height as i32) } else { height as i32 };
out.extend((info_len as u32).to_le_bytes());
out.extend(width.to_le_bytes());
out.extend(h.to_le_bytes());
out.extend(1u16.to_le_bytes());
out.extend(u16::from(o.depth).to_le_bytes());
out.extend(compression.to_le_bytes());
out.extend(data_len.to_le_bytes());
out.extend(ppm.to_le_bytes());
out.extend(ppm.to_le_bytes());
out.extend((entries as u32).to_le_bytes());
out.extend(0u32.to_le_bytes());
if info_len == 108 {
// Red, green, blue and alpha masks, sRGB, then unused end points and gammas.
for mask in [0x00FF_0000u32, 0x0000_FF00, 0x0000_00FF, 0xFF00_0000, u32::from_be_bytes(*b"sRGB")] {
out.extend(mask.to_le_bytes());
}
out.extend([0; 48]);
}
}
for i in 0..entries {
let [r, g, b] = colors.get(i).copied().unwrap_or_default();
out.extend(&[b, g, r, 0][..entry]);
}
out.extend_from_slice(data);
Ok(out)
}
/// One row of palette indices run-length encoded (RLE8 at 8 bits, RLE4 at 4): runs of one index
/// as (count, index), other stretches of three pixels or more in absolute mode (0, count, the
/// pixels packed, padded to a word).
fn rle_row(row: &[u8], bits: u8, out: &mut Vec<u8>) {
let run_byte = |v: u8| if bits == 4 { (v & 15) << 4 | (v & 15) } else { v };
let mut i = 0;
while let Some(&v) = row.get(i) {
let run = row.get(i..).map_or(1, |r| r.iter().take(255).take_while(|x| **x == v).count());
if run >= 2 {
out.extend([run as u8, run_byte(v)]);
i += run;
continue;
}
// Up to the next pair of equal pixels.
let mut j = i + 1;
while j < row.len() && j - i < 255 && row.get(j..j + 2).is_none_or(|p| p[0] != p[1]) {
j += 1;
}
let lit = row.get(i..j).unwrap_or_default();
if lit.len() < 3 {
// Absolute mode needs three pixels: runs of one.
lit.iter().for_each(|x| out.extend([1, run_byte(*x)]));
} else {
out.extend([0, lit.len() as u8]);
let start = out.len();
match bits {
4 => out.extend(lit.chunks(2).map(|p| (p[0] & 15) << 4 | p.get(1).map_or(0, |x| x & 15))),
_ => out.extend_from_slice(lit),
}
if (out.len() - start) % 2 == 1 {
out.push(0);
}
}
i = j;
}
}
+11 -3
View File
@@ -1,9 +1,10 @@
//! Raster export: render a document region with [`RasterExportOptions`] (resolution, background,
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or
//! a palette image ([`quantize`]) as PNG-8 or [`gif`], or [`tiff`] (RGB, CMYK or grey, LZW, either
//! byte order, the profile).
//! a palette image ([`quantize`]) as PNG-8 or [`gif`], [`tiff`] (RGB, CMYK or grey, LZW, either byte
//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`].
pub mod bmp;
pub mod gif;
pub mod jpeg;
pub mod png;
@@ -25,6 +26,7 @@ pub enum RasterFormat {
Png8,
Gif,
Tiff,
Bmp,
}
/// How a raster export renders and encodes.
@@ -41,10 +43,12 @@ pub struct RasterExportOptions {
pub quality: u8,
/// JPEG colour model, coding and profile.
pub jpeg: jpeg::JpegOptions,
/// PNG-8 and GIF: the colour reduction.
/// PNG-8 and GIF: the colour reduction (also BMP's at 1, 4 and 8 bits).
pub palette: quantize::PaletteOptions,
/// TIFF colour model, compression, byte order and profile.
pub tiff: tiff::TiffOptions,
/// BMP layout, depth, compression and row order.
pub bmp: bmp::BmpOptions,
}
impl Default for RasterExportOptions {
@@ -58,6 +62,7 @@ impl Default for RasterExportOptions {
jpeg: jpeg::JpegOptions::default(),
palette: quantize::PaletteOptions::default(),
tiff: tiff::TiffOptions::default(),
bmp: bmp::BmpOptions::default(),
}
}
}
@@ -103,6 +108,7 @@ impl RasterExportOptions {
}
}
RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff),
RasterFormat::Bmp => bmp::encode(&img.to_straight(), img.width, img.height, self.ppi, &self.bmp, &self.palette),
}
}
@@ -179,6 +185,8 @@ fn drawn_bounds(n: &Node) -> Option<Rect> {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_bmp;
#[cfg(test)]
mod tests_jpeg;
#[cfg(test)]
mod tests_palette;
+1 -1
View File
@@ -175,7 +175,7 @@ pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
/// Rows of one-byte pixels packed `bits` per pixel, most significant first (each row starts on a
/// byte); at 8 bits the pixels as they are.
fn pack(px: &[u8], w: u32, h: u32, bits: u8) -> Cow<'_, [u8]> {
pub(super) fn pack(px: &[u8], w: u32, h: u32, bits: u8) -> Cow<'_, [u8]> {
if bits >= 8 {
return Cow::Borrowed(px);
}
+1 -1
View File
@@ -202,7 +202,7 @@ pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Ind
}
/// Lightness as a grey value (Rec. 601 luma).
fn luma([r, g, b]: [u8; 3]) -> u8 {
pub(super) fn luma([r, g, b]: [u8; 3]) -> u8 {
((r as u32 * 299 + g as u32 * 587 + b as u32 * 114 + 500) / 1000) as u8
}
+133
View File
@@ -0,0 +1,133 @@
//! BMP export: depths, palettes and RLE, row order, layouts, alpha.
use super::bmp::{self, BmpOptions, DEPTHS};
use super::on_white;
use super::png::pixels_per_metre;
use super::quantize::PaletteOptions;
pub(super) fn u16_at(f: &[u8], at: usize) -> u16 {
u16::from_le_bytes(f[at..at + 2].try_into().unwrap())
}
fn u32_at(f: &[u8], at: usize) -> u32 {
u32::from_le_bytes(f[at..at + 4].try_into().unwrap())
}
/// Straight RGBA: bands of a few colours with runs (as art is), a transparent and a half
/// transparent pixel in the first row.
pub(super) fn art(w: u32, h: u32) -> Vec<u8> {
let colors = [[230, 40, 30, 255], [30, 90, 200, 255], [250, 250, 250, 255], [20, 160, 60, 255]];
let mut px: Vec<u8> = (0..w * h).flat_map(|i| colors[((i % w) * 4 / w + (i / w) % 2) as usize % 4]).collect();
px[..4].copy_from_slice(&[0, 0, 0, 0]);
px[4..8].copy_from_slice(&[100, 0, 0, 128]);
px
}
pub(super) fn decode(file: &[u8], format: image::ImageFormat) -> image::RgbaImage {
image::load_from_memory_with_format(file, format).expect("decodes").to_rgba8()
}
fn bmp(px: &[u8], w: u32, h: u32, o: &BmpOptions) -> Vec<u8> {
bmp::encode(px, w, h, 150.0, o, &PaletteOptions::default()).unwrap()
}
#[test]
fn every_depth_writes_its_bit_count_and_decodes() {
let (w, h) = (13, 7);
let px = art(w, h);
for depth in DEPTHS {
let file = bmp(&px, w, h, &BmpOptions { depth, ..Default::default() });
assert_eq!(&file[..2], b"BM");
assert_eq!(u32_at(&file, 2) as usize, file.len(), "file size");
assert_eq!(u16_at(&file, 28), u16::from(depth), "bit count");
assert_eq!(u32_at(&file, 38), pixels_per_metre(150.0), "resolution");
let img = decode(&file, image::ImageFormat::Bmp);
assert_eq!(img.dimensions(), (w, h));
let got = img.get_pixel(5, 3).0;
let want = on_white(px[(3 * w as usize + 5) * 4..][..4].try_into().unwrap());
match depth {
1 => assert!(got[..3].iter().all(|c| *c == 0 || *c == 255), "black and white: {got:?}"),
16 => assert!(got[..3].iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 8), "5 bits a channel: {got:?} vs {want:?}"),
24 | 4 | 8 => assert_eq!(got[..3], want, "depth {depth}: few colours keep theirs exactly"),
_ => {}
}
if depth == 32 {
assert_eq!(u32_at(&file, 14), 108, "a version 4 header with an alpha mask");
assert_eq!(img.get_pixel(0, 0).0[3], 0, "transparency kept");
assert_eq!(img.get_pixel(1, 0).0[3], 128);
} else {
assert_eq!(img.get_pixel(0, 0).0, [255; 4], "flattened on white");
}
}
}
#[test]
fn rle8_and_rle4_decode_like_uncompressed() {
let (w, h) = (61, 9);
let px = art(w, h);
for depth in [4, 8] {
let plain = bmp(&px, w, h, &BmpOptions { depth, ..Default::default() });
let rle = bmp(&px, w, h, &BmpOptions { depth, rle: true, ..Default::default() });
assert_eq!(u32_at(&rle, 30), if depth == 8 { 1 } else { 2 }, "BI_RLE8 / BI_RLE4");
assert_eq!(u32_at(&rle, 34) as usize, rle.len() - u32_at(&rle, 10) as usize, "the compressed size");
assert!(rle.len() < plain.len(), "{} vs {}", rle.len(), plain.len());
assert_eq!(decode(&rle, image::ImageFormat::Bmp), decode(&plain, image::ImageFormat::Bmp), "depth {depth}");
}
// Rows of many different pixels go in absolute mode.
let noisy: Vec<u8> = (0..40u32 * 4).flat_map(|i| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 13 % 256) as u8, 255]).collect();
let plain = bmp(&noisy, 40, 4, &BmpOptions { depth: 8, ..Default::default() });
let rle = bmp(&noisy, 40, 4, &BmpOptions { depth: 8, rle: true, ..Default::default() });
assert_eq!(decode(&rle, image::ImageFormat::Bmp), decode(&plain, image::ImageFormat::Bmp));
}
#[test]
fn flipped_rows_have_a_negative_height() {
let (w, h) = (6, 5);
let px = art(w, h);
let up = bmp(&px, w, h, &BmpOptions::default());
let down = bmp(&px, w, h, &BmpOptions { top_down: true, ..Default::default() });
assert_eq!(u32_at(&up, 22) as i32, 5);
assert_eq!(u32_at(&down, 22) as i32, -5);
let offset = u32_at(&down, 10) as usize;
// Top-down: the first row stored is the image's first (its transparent pixel, on white).
assert_eq!(&down[offset..offset + 3], &[255, 255, 255]);
assert_eq!(decode(&up, image::ImageFormat::Bmp), decode(&down, image::ImageFormat::Bmp));
}
#[test]
fn os2_bitmaps_have_a_core_header() {
let (w, h) = (9, 4);
let px = art(w, h);
for depth in bmp::OS2_DEPTHS {
let file = bmp(&px, w, h, &BmpOptions { os2: true, depth, ..Default::default() });
assert_eq!(u32_at(&file, 14), 12, "core header");
assert_eq!((u16_at(&file, 18), u16_at(&file, 20), u16_at(&file, 24)), (9, 4, u16::from(depth)));
// A three-byte entry for every index.
let entries = if depth <= 8 { 1 << depth } else { 0 };
assert_eq!(u32_at(&file, 10), 14 + 12 + entries * 3);
let windows = bmp(&px, w, h, &BmpOptions { depth, ..Default::default() });
assert_eq!(decode(&file, image::ImageFormat::Bmp), decode(&windows, image::ImageFormat::Bmp), "depth {depth}");
}
}
#[test]
fn greys_and_impossible_combinations() {
let (w, h) = (8, 2);
let px = art(w, h);
for depth in [8, 24] {
let img = decode(&bmp(&px, w, h, &BmpOptions { depth, gray: true, ..Default::default() }), image::ImageFormat::Bmp);
assert!(img.pixels().all(|p| p[0] == p[1] && p[1] == p[2]), "depth {depth}: greys");
}
let bad = [
BmpOptions { depth: 3, ..Default::default() },
BmpOptions { os2: true, depth: 32, ..Default::default() },
BmpOptions { rle: true, ..Default::default() },
BmpOptions { rle: true, depth: 8, os2: true, ..Default::default() },
BmpOptions { rle: true, depth: 8, top_down: true, ..Default::default() },
BmpOptions { os2: true, top_down: true, ..Default::default() },
];
for o in bad {
assert!(o.check().is_err() && bmp::encode(&px, w, h, 72.0, &o, &PaletteOptions::default()).is_err(), "{o:?}");
}
assert!(bmp::encode(&px[..8], w, h, 72.0, &BmpOptions::default(), &PaletteOptions::default()).is_err(), "short buffer");
}