///| A dependency-free Netpbm loader supporting the formats most useful
///| for tests and small tools: P1 (ASCII bitmap), P2 (ASCII grayscale), P4 (bitmap) and
///| P5 (binary grayscale). All four decode into a grayscale `Image`.
///| Advance past whitespace and `#` comments, returning the first
///|
/// non-comment, non-whitespace offset at or after `pos`.
fn skip_ws_and_comments(data : Bytes, pos : Int) -> Int {
let n = data.length()
let mut p = pos
while p < n {
let c = data.at(p).to_int()
if c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d {
p = p + 1
} else if c == 0x23 {
while p < n && data.at(p).to_int() != 0x0a {
p = p + 1
}
} else {
break
}
}
p
}
///| Read a decimal ASCII integer token starting at `pos`, returning the value
///|
/// and the offset just past the last digit.
fn read_token_int(data : Bytes, pos : Int) -> (Int, Int) {
let n = data.length()
let mut q = skip_ws_and_comments(data, pos)
let mut v = 0
while q < n {
let c = data.at(q).to_int()
if c >= 0x30 && c <= 0x39 {
v = v * 10 + (c - 0x30)
q = q + 1
} else {
break
}
}
(v, q)
}
///|
/// Decode a P5 (binary grayscale) raster, rescaling `maxval` to 255.
fn decode_p5(
data : Bytes,
width : Int,
height : Int,
data_start : Int,
maxval : Int,
) -> Image {
let pixels = Bytes::makei(width * height, fn(i) {
let v = data.at(data_start + i).to_int()
(v * 255 / maxval).to_byte()
})
Image::new(width, height, pixels)
}
///| Decode a P2 (ASCII grayscale) raster, rescaling `maxval` to 255.
///
///| Unlike the binary formats, P2 pixel values are whitespace-separated ASCII
///|
/// numbers, so the token scanner must advance through the raster sequentially.
fn decode_p2(
data : Bytes,
width : Int,
height : Int,
data_start : Int,
maxval : Int,
) -> Image {
let pixels = Array::make(width * height, b'\x00')
let mut p = data_start
for i = 0; i < width * height; i = i + 1 {
let (v, q) = read_token_int(data, p)
pixels[i] = (v * 255 / maxval).to_byte()
p = q
}
Image::new(width, height, Bytes::from_array(pixels))
}
///|
/// Decode a P1 (ASCII bitmap) raster. In PBM convention `1` is black and `0`
/// is white, which maps directly onto our ink/paper convention (`0` ink, `255`
/// paper).
fn decode_p1(
data : Bytes,
width : Int,
height : Int,
data_start : Int,
) -> Image {
let pixels = Array::make(width * height, b'\xff')
let mut p = data_start
for i = 0; i < width * height; i = i + 1 {
let (v, q) = read_token_int(data, p)
if v == 1 {
pixels[i] = b'\x00'
} else {
pixels[i] = b'\xff'
}
p = q
}
Image::new(width, height, Bytes::from_array(pixels))
}
///| Decode a P4 (packed bitmap) raster. A set bit is black (`0`) and a clear
///|
/// bit is white (`255`); bits are packed MSB-first, rows padded to bytes.
fn decode_p4(
data : Bytes,
width : Int,
height : Int,
data_start : Int,
) -> Image {
let row_bytes = (width + 7) / 8
let pixels = Bytes::makei(width * height, fn(i) {
let x = i % width
let y = i / width
let byte = data.at(data_start + y * row_bytes + x / 8).to_int()
let bit = (byte >> (7 - x % 8)) & 1
if bit == 0 {
b'\xff'
} else {
b'\x00'
}
})
Image::new(width, height, pixels)
}
///| Parse a Netpbm image (`P1`, `P2`, `P4` or `P5`) into a grayscale `Image`.
///
///| Raises `DecodeError` describing the problem when the input is malformed or
///|
/// uses a feature outside the supported subset.
///| Encode an image as a P5 (binary grayscale) PGM with `maxval` 255. The
///
///| header is ASCII and the raster follows byte for byte, so the result round-
///|
/// trips through `parse_pgm`.
pub fn write_pgm_p5(img : Image) -> Bytes {
let header = "P5\n\{img.width} \{img.height}\n255\n"
let hb = @utf8.encode(header)
let n = img.pixel_count()
let out = Array::make(hb.length() + n, b'\x00')
for i = 0; i < hb.length(); i = i + 1 {
out[i] = hb.at(i)
}
for i = 0; i < n; i = i + 1 {
out[hb.length() + i] = img.pixels.at(i)
}
Bytes::from_array(out)
}
///|
/// Parse a Netpbm image (`P1`, `P2`, `P4` or `P5`) into a grayscale `Image`.
///
pub fn parse_pgm(data : Bytes) -> Image raise DecodeError {
if data.length() < 2 {
raise DecodeError("PGM: buffer too short")
}
if data.at(0) != b'P' {
raise DecodeError("PGM: not a Netpbm file")
}
let magic = data.at(1).to_int()
let (width, pos1) = read_token_int(data, 2)
let (height, pos2) = read_token_int(data, pos1)
if width <= 0 || height <= 0 {
raise DecodeError("PGM: invalid dimensions")
}
if magic == 0x35 {
// P5: binary grayscale. A single whitespace byte follows `maxval`.
let (maxval, pos3) = read_token_int(data, pos2)
if maxval <= 0 || maxval > 255 {
raise DecodeError("PGM: maxval outside 1..255 is not supported")
}
decode_p5(data, width, height, pos3 + 1, maxval)
} else if magic == 0x32 {
// P2: ASCII grayscale.
let (maxval, pos3) = read_token_int(data, pos2)
if maxval <= 0 || maxval > 255 {
raise DecodeError("PGM: maxval outside 1..255 is not supported")
}
decode_p2(data, width, height, pos3, maxval)
} else if magic == 0x34 {
// P4: packed bitmap, no maxval token.
decode_p4(data, width, height, pos2 + 1)
} else if magic == 0x31 {
// P1: ASCII bitmap (1 = black, 0 = white), no maxval token.
decode_p1(data, width, height, pos2)
} else {
raise DecodeError("PGM: unsupported format (only P1, P2, P4 and P5)")
}
}