浏览代码

Merge pull request #1726 from WalterPlinge/image-netpbm

Add Netpbm image format support
Jeroen van Rijn 3 年之前
父节点
当前提交
964ab4814c

+ 67 - 13
core/image/common.odin

@@ -45,7 +45,7 @@ Image :: struct {
 	width:         int,
 	width:         int,
 	height:        int,
 	height:        int,
 	channels:      int,
 	channels:      int,
-	depth:         int,
+	depth:         int, // Channel depth in bits, typically 8 or 16
 	pixels:        bytes.Buffer,
 	pixels:        bytes.Buffer,
 	/*
 	/*
 		Some image loaders/writers can return/take an optional background color.
 		Some image loaders/writers can return/take an optional background color.
@@ -57,6 +57,7 @@ Image :: struct {
 }
 }
 
 
 Image_Metadata :: union {
 Image_Metadata :: union {
+	^Netpbm_Info,
 	^PNG_Info,
 	^PNG_Info,
 	^QOI_Info,
 	^QOI_Info,
 }
 }
@@ -140,18 +141,20 @@ Option :: enum {
 	alpha_drop_if_present,         // Unimplemented for QOI. Returns error.
 	alpha_drop_if_present,         // Unimplemented for QOI. Returns error.
 	alpha_premultiply,             // Unimplemented for QOI. Returns error.
 	alpha_premultiply,             // Unimplemented for QOI. Returns error.
 	blend_background,              // Ignored for non-PNG formats
 	blend_background,              // Ignored for non-PNG formats
+
 	// Unimplemented
 	// Unimplemented
 	do_not_expand_grayscale,
 	do_not_expand_grayscale,
 	do_not_expand_indexed,
 	do_not_expand_indexed,
 	do_not_expand_channels,
 	do_not_expand_channels,
 
 
 	// SAVE OPTIONS
 	// SAVE OPTIONS
-	qoi_all_channels_linear,       // QOI, informative info. If not set, defaults to sRGB with linear alpha.
+	qoi_all_channels_linear,       // QOI, informative only. If not set, defaults to sRGB with linear alpha.
 }
 }
 Options :: distinct bit_set[Option]
 Options :: distinct bit_set[Option]
 
 
 Error :: union #shared_nil {
 Error :: union #shared_nil {
 	General_Image_Error,
 	General_Image_Error,
+	Netpbm_Error,
 	PNG_Error,
 	PNG_Error,
 	QOI_Error,
 	QOI_Error,
 
 
@@ -164,11 +167,68 @@ Error :: union #shared_nil {
 
 
 General_Image_Error :: enum {
 General_Image_Error :: enum {
 	None = 0,
 	None = 0,
-	Invalid_Image_Dimensions,
+	// File I/O
+	Unable_To_Read_File,
+	Unable_To_Write_File,
+
+	// Invalid
+	Invalid_Signature,
+	Invalid_Input_Image,
 	Image_Dimensions_Too_Large,
 	Image_Dimensions_Too_Large,
+	Invalid_Image_Dimensions,
+	Invalid_Number_Of_Channels,
 	Image_Does_Not_Adhere_to_Spec,
 	Image_Does_Not_Adhere_to_Spec,
-	Invalid_Input_Image,
+	Invalid_Image_Depth,
+	Invalid_Bit_Depth,
+	Invalid_Color_Space,
+
+	// More data than pixels to decode into, for example.
+	Corrupt,
+
+	// Output buffer is the wrong size
 	Invalid_Output,
 	Invalid_Output,
+
+	// Allocation
+	Unable_To_Allocate_Or_Resize,
+}
+
+/*
+	Netpbm-specific definitions
+*/
+Netpbm_Format :: enum {
+	P1, P2, P3, P4, P5, P6, P7, Pf, PF,
+}
+
+Netpbm_Header :: struct {
+	format:        Netpbm_Format,
+	width:         int,
+	height:        int,
+	channels:      int,
+	depth:         int,
+	maxval:        int,
+	tupltype:      string,
+	scale:         f32,
+	little_endian: bool,
+}
+
+Netpbm_Info :: struct {
+	header: Netpbm_Header,
+}
+
+Netpbm_Error :: enum {
+	None = 0,
+
+	// reading
+	Invalid_Header_Token_Character,
+	Incomplete_Header,
+	Invalid_Header_Value,
+	Duplicate_Header_Field,
+	Buffer_Too_Small,
+	Invalid_Buffer_ASCII_Token,
+	Invalid_Buffer_Value,
+
+	// writing
+	Invalid_Format,
 }
 }
 
 
 /*
 /*
@@ -176,7 +236,6 @@ General_Image_Error :: enum {
 */
 */
 PNG_Error :: enum {
 PNG_Error :: enum {
 	None = 0,
 	None = 0,
-	Invalid_PNG_Signature,
 	IHDR_Not_First_Chunk,
 	IHDR_Not_First_Chunk,
 	IHDR_Corrupt,
 	IHDR_Corrupt,
 	IDAT_Missing,
 	IDAT_Missing,
@@ -292,15 +351,10 @@ PNG_Interlace_Method :: enum u8 {
 */
 */
 QOI_Error :: enum {
 QOI_Error :: enum {
 	None = 0,
 	None = 0,
-	Invalid_QOI_Signature,
-	Invalid_Number_Of_Channels, // QOI allows 3 or 4 channel data.
-	Invalid_Bit_Depth,          // QOI supports only 8-bit images, error only returned from writer.
-	Invalid_Color_Space,        // QOI allows 0 = sRGB or 1 = linear.
-	Corrupt,                    // More data than pixels to decode into, for example.
 	Missing_Or_Corrupt_Trailer, // Image seemed to have decoded okay, but trailer is missing or corrupt.
 	Missing_Or_Corrupt_Trailer, // Image seemed to have decoded okay, but trailer is missing or corrupt.
 }
 }
 
 
-QOI_Magic :: u32be(0x716f6966)      // "qoif"
+QOI_Magic :: u32be(0x716f6966) // "qoif"
 
 
 QOI_Color_Space :: enum u8 {
 QOI_Color_Space :: enum u8 {
 	sRGB   = 0,
 	sRGB   = 0,
@@ -1125,10 +1179,10 @@ write_bytes :: proc(buf: ^bytes.Buffer, data: []u8) -> (err: compress.General_Er
 		return nil
 		return nil
 	} else if len(data) == 1 {
 	} else if len(data) == 1 {
 		if bytes.buffer_write_byte(buf, data[0]) != nil {
 		if bytes.buffer_write_byte(buf, data[0]) != nil {
-			return compress.General_Error.Resize_Failed
+			return .Resize_Failed
 		}
 		}
 	} else if n, _ := bytes.buffer_write(buf, data); n != len(data) {
 	} else if n, _ := bytes.buffer_write(buf, data); n != len(data) {
-		return compress.General_Error.Resize_Failed
+		return .Resize_Failed
 	}
 	}
 	return nil
 	return nil
 }
 }

+ 32 - 0
core/image/netpbm/doc.odin

@@ -0,0 +1,32 @@
+/*
+Formats:
+	PBM (P1, P4): Portable Bit Map,       stores black and white images   (1 channel)
+	PGM (P2, P5): Portable Gray Map,      stores greyscale images         (1 channel, 1 or 2 bytes per value)
+	PPM (P3, P6): Portable Pixel Map,     stores colour images            (3 channel, 1 or 2 bytes per value)
+	PAM (P7    ): Portable Arbitrary Map, stores arbitrary channel images            (1 or 2 bytes per value)
+	PFM (Pf, PF): Portable Float Map,     stores floating-point images    (Pf: 1 channel, PF: 3 channel)
+
+Reading
+	All formats fill out header fields `format`, `width`, `height`, `channels`, `depth`
+	Specific formats use more fields
+		PGM, PPM, and PAM set `maxval`
+		PAM also sets `tupltype`, and is able to set `channels` to an arbitrary value
+		PFM sets `scale` and `little_endian`
+	Currently doesn't support reading multiple images from one binary-format file
+
+Writing
+	All formats require the header field `format` to be specified
+	Additional header fields are required for specific formats
+		PGM, PPM, and PAM require `maxval`
+		PAM also uses `tupltype`, though it may be left as default (empty or nil string)
+		PFM requires `scale` and `little_endian`, though the latter may be left untouched (default is false)
+
+Some syntax differences from the specifications:
+	`channels` stores what the PAM specification calls `depth`
+	`depth` instead stores how many bytes will fit `maxval` (should only be 1, 2, or 4)
+	`scale` and `little_endian` are separated, so the `header` will always store a positive `scale`
+	`little_endian` will only be true for a negative `scale` PFM, every other format will be false
+	`little_endian` only describes the netpbm data being read/written, the image buffer will be native
+*/
+
+package netpbm

+ 28 - 0
core/image/netpbm/helpers.odin

@@ -0,0 +1,28 @@
+package netpbm
+
+import "core:bytes"
+import "core:image"
+
+destroy :: proc(img: ^image.Image) -> bool {
+	if img == nil do return false
+
+	defer free(img)
+	bytes.buffer_destroy(&img.pixels)
+
+	//! TEMP CAST
+	info, ok := img.metadata.(^image.Netpbm_Info)
+	if !ok do return false
+
+	header_destroy(&info.header)
+	free(info)
+	img.metadata = nil
+
+	return true
+}
+
+header_destroy :: proc(using header: ^Header) {
+	if format == .P7 && tupltype != "" {
+		delete(tupltype)
+		tupltype = ""
+	}
+}

+ 751 - 0
core/image/netpbm/netpbm.odin

@@ -0,0 +1,751 @@
+package netpbm
+
+import "core:bytes"
+import "core:fmt"
+import "core:image"
+import "core:mem"
+import "core:os"
+import "core:strconv"
+import "core:strings"
+import "core:unicode"
+
+Image        :: image.Image
+Format       :: image.Netpbm_Format
+Header       :: image.Netpbm_Header
+Info         :: image.Netpbm_Info
+Error        :: image.Error
+Format_Error :: image.Netpbm_Error
+
+Formats :: bit_set[Format]
+PBM     :: Formats{.P1, .P4}
+PGM     :: Formats{.P2, .P5}
+PPM     :: Formats{.P3, .P6}
+PNM     :: PBM + PGM + PPM
+PAM     :: Formats{.P7}
+PFM     :: Formats{.Pf, .PF}
+ASCII   :: Formats{.P1, .P2, .P3}
+BINARY  :: Formats{.P4, .P5, .P6} + PAM + PFM
+
+load :: proc {
+	load_from_file,
+	load_from_buffer,
+}
+
+load_from_file :: proc(filename: string, allocator := context.allocator) -> (img: ^Image, err: Error) {
+	context.allocator = allocator
+
+	data, ok := os.read_entire_file(filename); defer delete(data)
+	if !ok {
+		err = .Unable_To_Read_File
+		return
+	}
+
+	return load_from_buffer(data)
+}
+
+load_from_buffer :: proc(data: []byte, allocator := context.allocator) -> (img: ^Image, err: Error) {
+	context.allocator = allocator
+
+	img = new(Image)
+
+	header: Header; defer header_destroy(&header)
+	header_size: int
+	header, header_size = parse_header(data) or_return
+
+	img_data := data[header_size:]
+	decode_image(img, header, img_data) or_return
+
+	info := new(Info)
+	info.header = header
+	if header.format == .P7 && header.tupltype != "" {
+		info.header.tupltype = strings.clone(header.tupltype)
+	}
+	img.metadata = info
+
+	return img, nil
+}
+
+save :: proc {
+	save_to_file,
+	save_to_buffer,
+}
+
+save_to_file :: proc(filename: string, img: ^Image, custom_info: Info = {}, allocator := context.allocator) -> (err: Error) {
+	context.allocator = allocator
+
+	data: []byte; defer delete(data)
+	data = save_to_buffer(img, custom_info) or_return
+
+	if ok := os.write_entire_file(filename, data); !ok {
+		return .Unable_To_Write_File
+	}
+
+	return Format_Error.None
+}
+
+save_to_buffer :: proc(img: ^Image, custom_info: Info = {}, allocator := context.allocator) -> (buffer: []byte, err: Error) {
+	context.allocator = allocator
+
+	info: Info = {}
+	if custom_info.header.width > 0 {
+		// Custom info has been set, use it.
+		info = custom_info
+	} else {
+		img_info, ok := img.metadata.(^image.Netpbm_Info)
+		if !ok {
+			// image doesn't have .Netpbm info, guess it
+			auto_info, auto_info_found := autoselect_pbm_format_from_image(img)
+			if auto_info_found {
+				info = auto_info
+			} else {
+				return {}, .Invalid_Input_Image
+			}
+		} else {
+			// use info as stored on image
+			info = img_info^
+		}
+	}
+
+	// using info so we can just talk about the header
+	using info
+
+	//? validation
+	if header.format in (PBM + PGM + Formats{.Pf}) && img.channels != 1 \
+	|| header.format in (PPM + Formats{.PF}) && img.channels != 3 {
+		err = .Invalid_Number_Of_Channels
+		return
+	}
+
+	if header.format in (PNM + PAM) {
+		if header.maxval <= int(max(u8)) && img.depth != 8 \
+		|| header.maxval > int(max(u8)) && header.maxval <= int(max(u16)) && img.depth != 16 {
+			err = .Invalid_Image_Depth
+			return
+		}
+	} else if header.format in PFM && img.depth != 32 {
+		err = .Invalid_Image_Depth
+		return
+	}
+
+	// we will write to a string builder
+	data: strings.Builder
+	strings.init_builder(&data)
+
+	// all PNM headers start with the format
+	fmt.sbprintf(&data, "%s\n", header.format)
+	if header.format in PNM {
+		fmt.sbprintf(&data, "%i %i\n", img.width, img.height)
+		if header.format not_in PBM {
+			fmt.sbprintf(&data, "%i\n", header.maxval)
+		}
+	} else if header.format in PAM {
+		if len(header.tupltype) > 0 {
+			fmt.sbprintf(&data, "WIDTH %i\nHEIGHT %i\nMAXVAL %i\nDEPTH %i\nTUPLTYPE %s\nENDHDR\n",
+				img.width, img.height, header.maxval, img.channels, header.tupltype)
+		} else {
+			fmt.sbprintf(&data, "WIDTH %i\nHEIGHT %i\nMAXVAL %i\nDEPTH %i\nENDHDR\n",
+				img.width, img.height, header.maxval, img.channels)
+		}
+
+	} else if header.format in PFM {
+		scale := -header.scale if header.little_endian else header.scale
+		fmt.sbprintf(&data, "%i %i\n%f\n", img.width, img.height, scale)
+	}
+
+	switch header.format {
+	// Compressed binary
+	case .P4:
+		header_buf := data.buf[:]
+		pixels := img.pixels.buf[:]
+
+		p4_buffer_size := (img.width / 8 + 1) * img.height
+		reserve(&data.buf, len(header_buf) + p4_buffer_size)
+
+		// we build up a byte value until it is completely filled
+		// or we reach the end the row
+		for y in 0 ..< img.height {
+			b: byte
+
+			for x in 0 ..< img.width {
+				i := y * img.width + x
+				bit := byte(7 - (x % 8))
+				v : byte = 0 if pixels[i] == 0 else 1
+				b |= (v << bit)
+
+				if bit == 0 {
+					append(&data.buf, b)
+					b = 0
+				}
+			}
+
+			if b != 0 {
+				append(&data.buf, b)
+				b = 0
+			}
+		}
+
+	// Simple binary
+	case .P5, .P6, .P7, .Pf, .PF:
+		header_buf := data.buf[:]
+		pixels := img.pixels.buf[:]
+
+		resize(&data.buf, len(header_buf) + len(pixels))
+		mem.copy(raw_data(data.buf[len(header_buf):]), raw_data(pixels), len(pixels))
+
+		// convert from native endianness
+		if img.depth == 16 {
+			pixels := mem.slice_data_cast([]u16be, data.buf[len(header_buf):])
+			for p in &pixels {
+				p = u16be(transmute(u16) p)
+			}
+		} else if header.format in PFM {
+			if header.little_endian {
+				pixels := mem.slice_data_cast([]f32le, data.buf[len(header_buf):])
+				for p in &pixels {
+					p = f32le(transmute(f32) p)
+				}
+			} else {
+				pixels := mem.slice_data_cast([]f32be, data.buf[len(header_buf):])
+				for p in &pixels {
+					p = f32be(transmute(f32) p)
+				}
+			}
+		}
+
+	// If-it-looks-like-a-bitmap ASCII
+	case .P1:
+		pixels := img.pixels.buf[:]
+		for y in 0 ..< img.height {
+			for x in 0 ..< img.width {
+				i := y * img.width + x
+				append(&data.buf, '0' if pixels[i] == 0 else '1')
+			}
+			append(&data.buf, '\n')
+		}
+
+	// Token ASCII
+	case .P2, .P3:
+		switch img.depth {
+		case 8:
+			pixels := img.pixels.buf[:]
+			for y in 0 ..< img.height {
+				for x in 0 ..< img.width {
+					i := y * img.width + x
+					for c in 0 ..< img.channels {
+						i := i * img.channels + c
+						fmt.sbprintf(&data, "%i ", pixels[i])
+					}
+					fmt.sbprint(&data, "\n")
+				}
+				fmt.sbprint(&data, "\n")
+			}
+
+		case 16:
+			pixels := mem.slice_data_cast([]u16, img.pixels.buf[:])
+			for y in 0 ..< img.height {
+				for x in 0 ..< img.width {
+					i := y * img.width + x
+					for c in 0 ..< img.channels {
+						i := i * img.channels + c
+						fmt.sbprintf(&data, "%i ", pixels[i])
+					}
+					fmt.sbprint(&data, "\n")
+				}
+				fmt.sbprint(&data, "\n")
+			}
+
+		case:
+			return data.buf[:], .Invalid_Image_Depth
+		}
+
+	case:
+		return data.buf[:], .Invalid_Format
+	}
+
+	return data.buf[:], Format_Error.None
+}
+
+parse_header :: proc(data: []byte, allocator := context.allocator) -> (header: Header, length: int, err: Error) {
+	context.allocator = allocator
+
+	// we need the signature and a space
+	if len(data) < 3 {
+		err = Format_Error.Incomplete_Header
+		return
+	}
+
+	if data[0] == 'P' {
+		switch data[1] {
+		case '1' ..= '6':
+			return _parse_header_pnm(data)
+		case '7':
+			return _parse_header_pam(data, allocator)
+		case 'F', 'f':
+			return _parse_header_pfm(data)
+		}
+	}
+
+	err = .Invalid_Signature
+	return
+}
+
+@(private)
+_parse_header_pnm :: proc(data: []byte) -> (header: Header, length: int, err: Error) {
+	SIG_LENGTH :: 2
+
+	{
+		header_formats := []Format{.P1, .P2, .P3, .P4, .P5, .P6}
+		header.format = header_formats[data[1] - '0' - 1]
+	}
+
+	// have a list of fielda for easy iteration
+	header_fields: []^int
+	if header.format in PBM {
+		header_fields = {&header.width, &header.height}
+		header.maxval = 1 // we know maxval for a bitmap
+	} else {
+		header_fields = {&header.width, &header.height, &header.maxval}
+	}
+
+	// we're keeping track of the header byte length
+	length = SIG_LENGTH
+
+	// loop state
+	in_comment := false
+	already_in_space := true
+	current_field := 0
+	current_value := header_fields[0]
+
+	parse_loop: for d, i in data[SIG_LENGTH:] {
+		length += 1
+
+		// handle comments
+		if in_comment {
+			switch d {
+			// comments only go up to next carriage return or line feed
+			case '\r', '\n':
+				in_comment = false
+			}
+			continue
+		} else if d == '#' {
+			in_comment = true
+			continue
+		}
+
+		// handle whitespace
+		in_space := unicode.is_white_space(rune(d))
+		if in_space {
+			if already_in_space {
+				continue
+			}
+			already_in_space = true
+
+			// switch to next value
+			current_field += 1
+			if current_field == len(header_fields) {
+				// header byte length is 1-index so we'll increment again
+				length += 1
+				break parse_loop
+			}
+			current_value = header_fields[current_field]
+		} else {
+			already_in_space = false
+
+			if !unicode.is_digit(rune(d)) {
+				err = Format_Error.Invalid_Header_Token_Character
+				return
+			}
+
+			val := int(d - '0')
+			current_value^ = current_value^ * 10 + val
+		}
+	}
+
+	// set extra info
+	header.channels = 3 if header.format in PPM else 1
+	header.depth    = 16 if header.maxval > int(max(u8)) else 8
+
+	// limit checking
+	if current_field < len(header_fields) {
+		err = Format_Error.Incomplete_Header
+		return
+	}
+
+	if header.width < 1 \
+	|| header.height < 1 \
+	|| header.maxval < 1 || header.maxval > int(max(u16)) {
+		fmt.printf("[pnm] Header: {{width = %v, height = %v, maxval: %v}}\n", header.width, header.height, header.maxval)
+		err = .Invalid_Header_Value
+		return
+	}
+
+	length -= 1
+	err = Format_Error.None
+	return
+}
+
+@(private)
+_parse_header_pam :: proc(data: []byte, allocator := context.allocator) -> (header: Header, length: int, err: Error) {
+	context.allocator = allocator
+
+	// the spec needs the newline apparently
+	if string(data[0:3]) != "P7\n" {
+		err = .Invalid_Signature
+		return
+	}
+	header.format = .P7
+
+	SIGNATURE_LENGTH :: 3
+	HEADER_END :: "ENDHDR\n"
+
+	// we can already work out the size of the header
+	header_end_index := strings.index(string(data), HEADER_END)
+	if header_end_index == -1 {
+		err = Format_Error.Incomplete_Header
+		return
+	}
+	length = header_end_index + len(HEADER_END)
+
+	// string buffer for the tupltype
+	tupltype: strings.Builder
+	strings.init_builder(&tupltype, context.temp_allocator); defer strings.destroy_builder(&tupltype)
+	fmt.sbprint(&tupltype, "")
+
+	// PAM uses actual lines, so we can iterate easily
+	line_iterator := string(data[SIGNATURE_LENGTH : header_end_index])
+	parse_loop: for line in strings.split_lines_iterator(&line_iterator) {
+		line := line
+
+		if len(line) == 0 || line[0] == '#' {
+			continue
+		}
+
+		field, ok := strings.fields_iterator(&line)
+		value := strings.trim_space(line)
+
+		// the field will change, but the logic stays the same
+		current_field: ^int
+
+		switch field {
+		case "WIDTH":  current_field = &header.width
+		case "HEIGHT": current_field = &header.height
+		case "DEPTH":  current_field = &header.channels
+		case "MAXVAL": current_field = &header.maxval
+
+		case "TUPLTYPE":
+			if len(value) == 0 {
+				err = .Invalid_Header_Value
+				return
+			}
+
+			if len(tupltype.buf) == 0 {
+				fmt.sbprint(&tupltype, value)
+			} else {
+				fmt.sbprint(&tupltype, "", value)
+			}
+
+			continue
+
+		case:
+			continue
+		}
+
+		if current_field^ != 0 {
+			err = Format_Error.Duplicate_Header_Field
+			return
+		}
+		current_field^, ok = strconv.parse_int(value)
+		if !ok {
+			err = Format_Error.Invalid_Header_Value
+			return
+		}
+	}
+
+	// extra info
+	header.depth = 16 if header.maxval > int(max(u8)) else 8
+
+	// limit checking
+	if header.width < 1 \
+	|| header.height < 1 \
+	|| header.maxval < 1 \
+	|| header.maxval > int(max(u16)) {
+		fmt.printf("[pam] Header: {{width = %v, height = %v, maxval: %v}}\n", header.width, header.height, header.maxval)
+		err = Format_Error.Invalid_Header_Value
+		return
+	}
+
+	header.tupltype = strings.clone(strings.to_string(tupltype))
+	err = Format_Error.None
+	return
+}
+
+@(private)
+_parse_header_pfm :: proc(data: []byte) -> (header: Header, length: int, err: Error) {
+	// we can just cycle through tokens for PFM
+	field_iterator := string(data)
+	field, ok := strings.fields_iterator(&field_iterator)
+
+	switch field {
+	case "Pf":
+		header.format = .Pf
+		header.channels = 1
+	case "PF":
+		header.format = .PF
+		header.channels = 3
+	case:
+		err = .Invalid_Signature
+		return
+	}
+
+	// floating point
+	header.depth = 32
+
+	// width
+	field, ok = strings.fields_iterator(&field_iterator)
+	if !ok {
+		err = Format_Error.Incomplete_Header
+		return
+	}
+	header.width, ok = strconv.parse_int(field)
+	if !ok {
+		err = Format_Error.Invalid_Header_Value
+		return
+	}
+
+	// height
+	field, ok = strings.fields_iterator(&field_iterator)
+	if !ok {
+		err = Format_Error.Incomplete_Header
+		return
+	}
+	header.height, ok = strconv.parse_int(field)
+	if !ok {
+		err = Format_Error.Invalid_Header_Value
+		return
+	}
+
+	// scale (sign is endianness)
+	field, ok = strings.fields_iterator(&field_iterator)
+	if !ok {
+		err = Format_Error.Incomplete_Header
+		return
+	}
+	header.scale, ok = strconv.parse_f32(field)
+	if !ok {
+		err = Format_Error.Invalid_Header_Value
+		return
+	}
+
+	if header.scale < 0.0 {
+		header.little_endian = true
+		header.scale = -header.scale
+	}
+
+	// pointer math to get header size
+	length = int((uintptr(raw_data(field_iterator)) + 1) - uintptr(raw_data(data)))
+
+	// limit checking
+	if header.width < 1 \
+	|| header.height < 1 \
+	|| header.scale == 0.0 {
+		fmt.printf("[pfm] Header: {{width = %v, height = %v, scale: %v}}\n", header.width, header.height, header.scale)
+		err = .Invalid_Header_Value
+		return
+	}
+
+	err = Format_Error.None
+	return
+}
+
+decode_image :: proc(img: ^Image, header: Header, data: []byte, allocator := context.allocator) -> (err: Error) {
+	assert(img != nil)
+	context.allocator = allocator
+
+	img.width    = header.width
+	img.height   = header.height
+	img.channels = header.channels
+	img.depth    = header.depth
+
+	buffer_size := image.compute_buffer_size(img.width, img.height, img.channels, img.depth)
+
+	// we can check data size for binary formats
+	if header.format in BINARY {
+		if len(data) < buffer_size {
+			fmt.printf("len(data): %v, buffer size: %v\n", len(data), buffer_size)
+			return .Buffer_Too_Small
+		}
+	}
+
+	// for ASCII and P4, we use length for the termination condition, so start at 0
+	// BINARY will be a simple memcopy so the buffer length should also be initialised
+	if header.format in ASCII || header.format == .P4 {
+		bytes.buffer_init_allocator(&img.pixels, 0, buffer_size)
+	} else {
+		bytes.buffer_init_allocator(&img.pixels, buffer_size, buffer_size)
+	}
+
+	switch header.format {
+	// Compressed binary
+	case .P4:
+		for d in data {
+			for b in 1 ..= 8 {
+				bit := byte(8 - b)
+				pix := (d >> bit) & 1
+				bytes.buffer_write_byte(&img.pixels, pix)
+				if len(img.pixels.buf) % img.width == 0 {
+					break
+				}
+			}
+
+			if len(img.pixels.buf) == cap(img.pixels.buf) {
+				break
+			}
+		}
+
+	// Simple binary
+	case .P5, .P6, .P7, .Pf, .PF:
+		copy(img.pixels.buf[:], data[:])
+
+		// convert to native endianness
+		if header.format in PFM {
+			pixels := mem.slice_data_cast([]f32, img.pixels.buf[:])
+			if header.little_endian {
+				for p in &pixels {
+					p = f32(transmute(f32le) p)
+				}
+			} else {
+				for p in &pixels {
+					p = f32(transmute(f32be) p)
+				}
+			}
+		} else {
+			if img.depth == 16 {
+				pixels := mem.slice_data_cast([]u16, img.pixels.buf[:])
+				for p in &pixels {
+					p = u16(transmute(u16be) p)
+				}
+			}
+		}
+
+	// If-it-looks-like-a-bitmap ASCII
+	case .P1:
+		for c in data {
+			switch c {
+			case '0', '1':
+				bytes.buffer_write_byte(&img.pixels, c - '0')
+			}
+
+			if len(img.pixels.buf) == cap(img.pixels.buf) {
+				break
+			}
+		}
+
+		if len(img.pixels.buf) < cap(img.pixels.buf) {
+			err = Format_Error.Buffer_Too_Small
+			return
+		}
+
+	// Token ASCII
+	case .P2, .P3:
+		field_iterator := string(data)
+		for field in strings.fields_iterator(&field_iterator) {
+			value, ok := strconv.parse_int(field)
+			if !ok {
+				err = Format_Error.Invalid_Buffer_ASCII_Token
+				return
+			}
+
+			//? do we want to enforce the maxval, the limit, or neither
+			if value > int(max(u16)) /*header.maxval*/ {
+				err = Format_Error.Invalid_Buffer_Value
+				return
+			}
+
+			switch img.depth {
+			case 8:
+				bytes.buffer_write_byte(&img.pixels, u8(value))
+			case 16:
+				vb := transmute([2]u8) u16(value)
+				bytes.buffer_write(&img.pixels, vb[:])
+			}
+
+			if len(img.pixels.buf) == cap(img.pixels.buf) {
+				break
+			}
+		}
+
+		if len(img.pixels.buf) < cap(img.pixels.buf) {
+			err = Format_Error.Buffer_Too_Small
+			return
+		}
+	}
+
+	err = Format_Error.None
+	return
+}
+
+// Automatically try to select an appropriate format to save to based on `img.channel` and `img.depth`
+autoselect_pbm_format_from_image :: proc(img: ^Image, prefer_binary := true, force_black_and_white := false, pfm_scale := f32(1.0)) -> (res: Info, ok: bool) {
+	/*
+		PBM (P1, P4): Portable Bit Map,       stores black and white images   (1 channel)
+		PGM (P2, P5): Portable Gray Map,      stores greyscale images         (1 channel, 1 or 2 bytes per value)
+		PPM (P3, P6): Portable Pixel Map,     stores colour images            (3 channel, 1 or 2 bytes per value)
+		PAM (P7    ): Portable Arbitrary Map, stores arbitrary channel images            (1 or 2 bytes per value)
+		PFM (Pf, PF): Portable Float Map,     stores floating-point images    (Pf: 1 channel, PF: 3 channel)
+
+		ASCII   :: Formats{.P1, .P2, .P3}
+	*/
+	using res.header
+
+	width    = img.width
+	height   = img.height
+	channels = img.channels
+	depth    = img.depth
+	maxval   = 255 if img.depth == 8 else 65535
+	little_endian = true if ODIN_ENDIAN == .Little else false
+
+	// Assume we'll find a suitable format
+	ok = true
+
+	switch img.channels {
+	case 1:
+		// Must be Portable Float Map
+		if img.depth == 32 {
+			format = .Pf
+			return
+		}
+
+		if force_black_and_white {
+			// Portable Bit Map
+			format = .P4 if prefer_binary else .P1
+			maxval = 1
+			return
+		} else {
+			// Portable Gray Map
+			format = .P5 if prefer_binary else .P2
+			return
+		}
+
+	case 3:
+		// Must be Portable Float Map
+		if img.depth == 32 {
+			format = .PF
+			return
+		}
+
+		// Portable Pixel Map
+		format = .P6 if prefer_binary else .P3
+		return
+
+	case:
+		// Portable Arbitrary Map
+		if img.depth == 8 || img.depth == 16 {
+			format = .P7
+			scale  = pfm_scale
+			return
+		}
+	}
+
+	// We couldn't find a suitable format
+	return {}, false
+}

+ 8 - 8
core/image/png/png.odin

@@ -238,7 +238,7 @@ append_chunk :: proc(list: ^[dynamic]image.PNG_Chunk, src: image.PNG_Chunk, allo
 	append(list, c)
 	append(list, c)
 	if len(list) != length + 1 {
 	if len(list) != length + 1 {
 		// Resize during append failed.
 		// Resize during append failed.
-		return mem.Allocator_Error.Out_Of_Memory
+		return .Unable_To_Allocate_Or_Resize
 	}
 	}
 
 
 	return
 	return
@@ -347,7 +347,7 @@ load_from_file :: proc(filename: string, options := Options{}, allocator := cont
 		return load_from_slice(data, options)
 		return load_from_slice(data, options)
 	} else {
 	} else {
 		img = new(Image)
 		img = new(Image)
-		return img, compress.General_Error.File_Not_Found
+		return img, .Unable_To_Read_File
 	}
 	}
 }
 }
 
 
@@ -381,7 +381,7 @@ load_from_context :: proc(ctx: ^$C, options := Options{}, allocator := context.a
 
 
 	signature, io_error := compress.read_data(ctx, Signature)
 	signature, io_error := compress.read_data(ctx, Signature)
 	if io_error != .None || signature != .PNG {
 	if io_error != .None || signature != .PNG {
-		return img, .Invalid_PNG_Signature
+		return img, .Invalid_Signature
 	}
 	}
 
 
 	idat: []u8
 	idat: []u8
@@ -747,7 +747,7 @@ load_from_context :: proc(ctx: ^$C, options := Options{}, allocator := context.a
 		dest_raw_size := compute_buffer_size(int(header.width), int(header.height), out_image_channels, 8)
 		dest_raw_size := compute_buffer_size(int(header.width), int(header.height), out_image_channels, 8)
 		t := bytes.Buffer{}
 		t := bytes.Buffer{}
 		if !resize(&t.buf, dest_raw_size) {
 		if !resize(&t.buf, dest_raw_size) {
-			return {}, mem.Allocator_Error.Out_Of_Memory
+			return {}, .Unable_To_Allocate_Or_Resize
 		}
 		}
 
 
 		i := 0; j := 0
 		i := 0; j := 0
@@ -828,7 +828,7 @@ load_from_context :: proc(ctx: ^$C, options := Options{}, allocator := context.a
 		dest_raw_size := compute_buffer_size(int(header.width), int(header.height), out_image_channels, 16)
 		dest_raw_size := compute_buffer_size(int(header.width), int(header.height), out_image_channels, 16)
 		t := bytes.Buffer{}
 		t := bytes.Buffer{}
 		if !resize(&t.buf, dest_raw_size) {
 		if !resize(&t.buf, dest_raw_size) {
-			return {}, mem.Allocator_Error.Out_Of_Memory
+			return {}, .Unable_To_Allocate_Or_Resize
 		}
 		}
 
 
 		p16 := mem.slice_data_cast([]u16, temp.buf[:])
 		p16 := mem.slice_data_cast([]u16, temp.buf[:])
@@ -1027,7 +1027,7 @@ load_from_context :: proc(ctx: ^$C, options := Options{}, allocator := context.a
 		dest_raw_size := compute_buffer_size(int(header.width), int(header.height), out_image_channels, 8)
 		dest_raw_size := compute_buffer_size(int(header.width), int(header.height), out_image_channels, 8)
 		t := bytes.Buffer{}
 		t := bytes.Buffer{}
 		if !resize(&t.buf, dest_raw_size) {
 		if !resize(&t.buf, dest_raw_size) {
-			return {}, mem.Allocator_Error.Out_Of_Memory
+			return {}, .Unable_To_Allocate_Or_Resize
 		}
 		}
 
 
 		p := mem.slice_data_cast([]u8, temp.buf[:])
 		p := mem.slice_data_cast([]u8, temp.buf[:])
@@ -1535,7 +1535,7 @@ defilter :: proc(img: ^Image, filter_bytes: ^bytes.Buffer, header: ^image.PNG_IH
 
 
 	num_bytes := compute_buffer_size(width, height, channels, depth == 16 ? 16 : 8)
 	num_bytes := compute_buffer_size(width, height, channels, depth == 16 ? 16 : 8)
 	if !resize(&img.pixels.buf, num_bytes) {
 	if !resize(&img.pixels.buf, num_bytes) {
-		return mem.Allocator_Error.Out_Of_Memory
+		return .Unable_To_Allocate_Or_Resize
 	}
 	}
 
 
 	filter_ok: bool
 	filter_ok: bool
@@ -1577,7 +1577,7 @@ defilter :: proc(img: ^Image, filter_bytes: ^bytes.Buffer, header: ^image.PNG_IH
 				temp: bytes.Buffer
 				temp: bytes.Buffer
 				temp_len := compute_buffer_size(x, y, channels, depth == 16 ? 16 : 8)
 				temp_len := compute_buffer_size(x, y, channels, depth == 16 ? 16 : 8)
 				if !resize(&temp.buf, temp_len) {
 				if !resize(&temp.buf, temp_len) {
-					return mem.Allocator_Error.Out_Of_Memory
+					return .Unable_To_Allocate_Or_Resize
 				}
 				}
 
 
 				params := Filter_Params{
 				params := Filter_Params{

+ 5 - 7
core/image/qoi/qoi.odin

@@ -12,14 +12,12 @@
 // The QOI specification is at https://qoiformat.org.
 // The QOI specification is at https://qoiformat.org.
 package qoi
 package qoi
 
 
-import "core:mem"
 import "core:image"
 import "core:image"
 import "core:compress"
 import "core:compress"
 import "core:bytes"
 import "core:bytes"
 import "core:os"
 import "core:os"
 
 
 Error   :: image.Error
 Error   :: image.Error
-General :: compress.General_Error
 Image   :: image.Image
 Image   :: image.Image
 Options :: image.Options
 Options :: image.Options
 
 
@@ -57,7 +55,7 @@ save_to_memory  :: proc(output: ^bytes.Buffer, img: ^Image, options := Options{}
 	max_size := pixels * (img.channels + 1) + size_of(image.QOI_Header) + size_of(u64be)
 	max_size := pixels * (img.channels + 1) + size_of(image.QOI_Header) + size_of(u64be)
 
 
 	if !resize(&output.buf, max_size) {
 	if !resize(&output.buf, max_size) {
-		return General.Resize_Failed
+		return .Unable_To_Allocate_Or_Resize
 	}
 	}
 
 
 	header := image.QOI_Header{
 	header := image.QOI_Header{
@@ -177,7 +175,7 @@ save_to_file :: proc(output: string, img: ^Image, options := Options{}, allocato
 	save_to_memory(out, img, options) or_return
 	save_to_memory(out, img, options) or_return
 	write_ok := os.write_entire_file(output, out.buf[:])
 	write_ok := os.write_entire_file(output, out.buf[:])
 
 
-	return nil if write_ok else General.Cannot_Open_File
+	return nil if write_ok else .Unable_To_Write_File
 }
 }
 
 
 save :: proc{save_to_memory, save_to_file}
 save :: proc{save_to_memory, save_to_file}
@@ -201,7 +199,7 @@ load_from_file :: proc(filename: string, options := Options{}, allocator := cont
 		return load_from_slice(data, options)
 		return load_from_slice(data, options)
 	} else {
 	} else {
 		img = new(Image)
 		img = new(Image)
-		return img, compress.General_Error.File_Not_Found
+		return img, .Unable_To_Read_File
 	}
 	}
 }
 }
 
 
@@ -221,7 +219,7 @@ load_from_context :: proc(ctx: ^$C, options := Options{}, allocator := context.a
 
 
 	header := image.read_data(ctx, image.QOI_Header) or_return
 	header := image.read_data(ctx, image.QOI_Header) or_return
 	if header.magic != image.QOI_Magic {
 	if header.magic != image.QOI_Magic {
-		return img, .Invalid_QOI_Signature
+		return img, .Invalid_Signature
 	}
 	}
 
 
 	if img == nil {
 	if img == nil {
@@ -264,7 +262,7 @@ load_from_context :: proc(ctx: ^$C, options := Options{}, allocator := context.a
 	bytes_needed := image.compute_buffer_size(int(header.width), int(header.height), img.channels, 8)
 	bytes_needed := image.compute_buffer_size(int(header.width), int(header.height), img.channels, 8)
 
 
 	if !resize(&img.pixels.buf, bytes_needed) {
 	if !resize(&img.pixels.buf, bytes_needed) {
-	 	return img, mem.Allocator_Error.Out_Of_Memory
+	 	return img, .Unable_To_Allocate_Or_Resize
 	}
 	}
 
 
 	/*
 	/*

+ 2 - 3
core/image/tga/tga.odin

@@ -17,7 +17,6 @@ import "core:bytes"
 import "core:os"
 import "core:os"
 
 
 Error   :: image.Error
 Error   :: image.Error
-General :: compress.General_Error
 Image   :: image.Image
 Image   :: image.Image
 Options :: image.Options
 Options :: image.Options
 
 
@@ -55,7 +54,7 @@ save_to_memory  :: proc(output: ^bytes.Buffer, img: ^Image, options := Options{}
 	necessary := pixels * img.channels + size_of(image.TGA_Header)
 	necessary := pixels * img.channels + size_of(image.TGA_Header)
 
 
 	if !resize(&output.buf, necessary) {
 	if !resize(&output.buf, necessary) {
-		return General.Resize_Failed
+		return .Unable_To_Allocate_Or_Resize
 	}
 	}
 
 
 	header := image.TGA_Header{
 	header := image.TGA_Header{
@@ -97,7 +96,7 @@ save_to_file :: proc(output: string, img: ^Image, options := Options{}, allocato
 	save_to_memory(out, img, options) or_return
 	save_to_memory(out, img, options) or_return
 	write_ok := os.write_entire_file(output, out.buf[:])
 	write_ok := os.write_entire_file(output, out.buf[:])
 
 
-	return nil if write_ok else General.Cannot_Open_File
+	return nil if write_ok else .Unable_To_Write_File
 }
 }
 
 
 save :: proc{save_to_memory, save_to_file}
 save :: proc{save_to_memory, save_to_file}

+ 156 - 21
tests/core/image/test_core_image.odin

@@ -13,6 +13,7 @@ import "core:testing"
 
 
 import "core:compress"
 import "core:compress"
 import "core:image"
 import "core:image"
+import pbm "core:image/netpbm"
 import "core:image/png"
 import "core:image/png"
 import "core:image/qoi"
 import "core:image/qoi"
 
 
@@ -1199,37 +1200,37 @@ Corrupt_PNG_Tests   := []PNG_Test{
 	{
 	{
 		"xs1n0g01", // signature byte 1 MSBit reset to zero
 		"xs1n0g01", // signature byte 1 MSBit reset to zero
 		{
 		{
-			{Default, .Invalid_PNG_Signature, {}, 0x_0000_0000},
+			{Default, .Invalid_Signature, {}, 0x_0000_0000},
 		},
 		},
 	},
 	},
 	{
 	{
 		"xs2n0g01", // signature byte 2 is a 'Q'
 		"xs2n0g01", // signature byte 2 is a 'Q'
 		{
 		{
-			{Default, .Invalid_PNG_Signature, {}, 0x_0000_0000},
+			{Default, .Invalid_Signature, {}, 0x_0000_0000},
 		},
 		},
 	},
 	},
 	{
 	{
 		"xs4n0g01", // signature byte 4 lowercase
 		"xs4n0g01", // signature byte 4 lowercase
 		{
 		{
-			{Default, .Invalid_PNG_Signature, {}, 0x_0000_0000},
+			{Default, .Invalid_Signature, {}, 0x_0000_0000},
 		},
 		},
 	},
 	},
 	{
 	{
 		"xs7n0g01", // 7th byte a space instead of control-Z
 		"xs7n0g01", // 7th byte a space instead of control-Z
 		{
 		{
-			{Default, .Invalid_PNG_Signature, {}, 0x_0000_0000},
+			{Default, .Invalid_Signature, {}, 0x_0000_0000},
 		},
 		},
 	},
 	},
 	{
 	{
 		"xcrn0g04", // added cr bytes
 		"xcrn0g04", // added cr bytes
 		{
 		{
-			{Default, .Invalid_PNG_Signature, {}, 0x_0000_0000},
+			{Default, .Invalid_Signature, {}, 0x_0000_0000},
 		},
 		},
 	},
 	},
 	{
 	{
 		"xlfn0g04", // added lf bytes
 		"xlfn0g04", // added lf bytes
 		{
 		{
-			{Default, .Invalid_PNG_Signature, {}, 0x_0000_0000},
+			{Default, .Invalid_Signature, {}, 0x_0000_0000},
 		},
 		},
 	},
 	},
 	{
 	{
@@ -1506,25 +1507,159 @@ run_png_suite :: proc(t: ^testing.T, suite: []PNG_Test) -> (subtotal: int) {
 
 
 				passed &= test.hash == png_hash
 				passed &= test.hash == png_hash
 
 
-				// Roundtrip through QOI to test the QOI encoder and decoder.
-				if passed && img.depth == 8 && (img.channels == 3 || img.channels == 4) {
-					qoi_buffer: bytes.Buffer
-					defer bytes.buffer_destroy(&qoi_buffer)
-					qoi_save_err := qoi.save(&qoi_buffer, img)
+				if passed {
+					// Roundtrip through QOI to test the QOI encoder and decoder.
+					if img.depth == 8 && (img.channels == 3 || img.channels == 4) {
+						qoi_buffer: bytes.Buffer
+						defer bytes.buffer_destroy(&qoi_buffer)
+						qoi_save_err := qoi.save(&qoi_buffer, img)
 
 
-					error  = fmt.tprintf("%v test %v QOI save failed with %v.", file.file, count, qoi_save_err)
-					expect(t, qoi_save_err == nil, error)
+						error  = fmt.tprintf("%v test %v QOI save failed with %v.", file.file, count, qoi_save_err)
+						expect(t, qoi_save_err == nil, error)
 
 
-					if qoi_save_err == nil {
-						qoi_img, qoi_load_err := qoi.load(qoi_buffer.buf[:])
-						defer qoi.destroy(qoi_img)
+						if qoi_save_err == nil {
+							qoi_img, qoi_load_err := qoi.load(qoi_buffer.buf[:])
+							defer qoi.destroy(qoi_img)
 
 
-						error  = fmt.tprintf("%v test %v QOI load failed with %v.", file.file, count, qoi_load_err)
-						expect(t, qoi_load_err == nil, error)
+							error  = fmt.tprintf("%v test %v QOI load failed with %v.", file.file, count, qoi_load_err)
+							expect(t, qoi_load_err == nil, error)
 
 
-						qoi_hash := hash.crc32(qoi_img.pixels.buf[:])
-						error  = fmt.tprintf("%v test %v QOI load hash is %08x, expected it match PNG's %08x with %v.", file.file, count, qoi_hash, png_hash, test.options)
-						expect(t, qoi_hash == png_hash, error)
+							qoi_hash := hash.crc32(qoi_img.pixels.buf[:])
+							error  = fmt.tprintf("%v test %v QOI load hash is %08x, expected it match PNG's %08x with %v.", file.file, count, qoi_hash, png_hash, test.options)
+							expect(t, qoi_hash == png_hash, error)
+						}
+					}
+
+					{
+						// Roundtrip through PBM to test the PBM encoders and decoders - prefer binary
+						pbm_buf, pbm_save_err := pbm.save_to_buffer(img)
+						defer delete(pbm_buf)
+
+						error = fmt.tprintf("%v test %v PBM save failed with %v.", file.file, count, pbm_save_err)
+						expect(t, pbm_save_err == nil, error)
+
+						if pbm_save_err == nil {
+							// Try to load it again.
+							pbm_img, pbm_load_err := pbm.load(pbm_buf)
+							defer pbm.destroy(pbm_img)
+
+							error  = fmt.tprintf("%v test %v PBM load failed with %v.", file.file, count, pbm_load_err)
+							expect(t, pbm_load_err == nil, error)
+
+							if pbm_load_err == nil {
+								pbm_hash := hash.crc32(pbm_img.pixels.buf[:])
+
+								error  = fmt.tprintf("%v test %v PBM load hash is %08x, expected it match PNG's %08x with %v.", file.file, count, pbm_hash, png_hash, test.options)
+								expect(t, pbm_hash == png_hash, error)
+							}
+						}
+					}
+
+					{
+						// Roundtrip through PBM to test the PBM encoders and decoders - prefer ASCII
+						pbm_info, pbm_format_selected := pbm.autoselect_pbm_format_from_image(img, false)
+
+						// We already tested the binary formats above.
+						if pbm_info.header.format in pbm.ASCII {
+							pbm_buf, pbm_save_err := pbm.save_to_buffer(img, pbm_info)
+							defer delete(pbm_buf)
+
+							error = fmt.tprintf("%v test %v PBM save failed with %v.", file.file, count, pbm_save_err)
+							expect(t, pbm_save_err == nil, error)
+
+							if pbm_save_err == nil {
+								// Try to load it again.
+								pbm_img, pbm_load_err := pbm.load(pbm_buf)
+								defer pbm.destroy(pbm_img)
+
+								error  = fmt.tprintf("%v test %v PBM load failed with %v.", file.file, count, pbm_load_err)
+								expect(t, pbm_load_err == nil, error)
+
+								if pbm_load_err == nil {
+									pbm_hash := hash.crc32(pbm_img.pixels.buf[:])
+
+									error  = fmt.tprintf("%v test %v PBM load hash is %08x, expected it match PNG's %08x with %v.", file.file, count, pbm_hash, png_hash, test.options)
+									expect(t, pbm_hash == png_hash, error)
+								}
+							}
+						}
+					}
+
+					{
+						// We still need to test Portable Float Maps
+						if (img.channels == 1 || img.channels == 3) && (img.depth == 8 || img.depth == 16) {
+
+							// Make temporary float image
+							float_img   := new(image.Image)
+							defer png.destroy(float_img)
+
+							float_img.width    = img.width
+							float_img.height   = img.height
+							float_img.channels = img.channels
+							float_img.depth    = 32
+
+							buffer_size := image.compute_buffer_size(img.width, img.height, img.channels, 32)
+							resize(&float_img.pixels.buf, buffer_size)
+
+							pbm_info := pbm.Info {
+								header = {
+									width         = img.width,
+									height        = img.height,
+									channels      = img.channels,
+									depth         = img.depth,
+									maxval        = 255 if img.depth == 8 else 65535,
+									little_endian = true if ODIN_ENDIAN == .Little else false,
+									scale         = 1.0,
+									format        = .Pf if img.channels == 1 else .PF,
+								},
+							}
+
+							// Transform data...
+							orig_float := mem.slice_data_cast([]f32, float_img.pixels.buf[:])
+
+							switch img.depth {
+							case 8:
+								for v, i in img.pixels.buf {
+									orig_float[i] = f32(v) / f32(256)
+								}
+							case 16:
+								wide := mem.slice_data_cast([]u16, img.pixels.buf[:])
+								for v, i in wide {
+									orig_float[i] = f32(v) / f32(65536)
+								}
+							}
+
+							float_pbm_buf, float_pbm_save_err := pbm.save_to_buffer(float_img, pbm_info)
+							defer delete(float_pbm_buf)
+
+							error = fmt.tprintf("%v test %v save as PFM failed with %v", file.file, count, float_pbm_save_err)
+							expect(t, float_pbm_save_err == nil, error)
+
+							if float_pbm_save_err == nil {
+								// Load float image and compare.
+								float_pbm_img, float_pbm_load_err := pbm.load(float_pbm_buf)
+								defer pbm.destroy(float_pbm_img)
+
+								error = fmt.tprintf("%v test %v PFM load failed with %v", file.file, count, float_pbm_load_err)
+								expect(t, float_pbm_load_err == nil, error)
+
+								load_float := mem.slice_data_cast([]f32, float_pbm_img.pixels.buf[:])
+
+								error = fmt.tprintf("%v test %v PFM load returned %v floats, expected %v", file.file, count, len(load_float), len(orig_float))
+								expect(t, len(load_float) == len(orig_float), error)
+
+								// Compare floats
+								equal := true
+								for orig, i in orig_float {
+									if orig != load_float[i] {
+										equal = false
+										break
+									}
+								}
+								error = fmt.tprintf("%v test %v PFM loaded floats to match", file.file, count)
+								expect(t, equal, error)
+							}
+						}
 					}
 					}
 				}
 				}