diff --git a/lib/src/buffer.dart b/lib/src/buffer.dart index 0ca28c9..0b9ca73 100644 --- a/lib/src/buffer.dart +++ b/lib/src/buffer.dart @@ -23,7 +23,7 @@ part 'buffer/pixels.dart'; /// representation of pixel data. /// /// {@category Buffers} -abstract base mixin class Buffer { +abstract base mixin class Buffer { /// Format of the pixel data in the buffer. PixelFormat get format; @@ -223,6 +223,43 @@ abstract base mixin class Buffer { } return rect.positions.map(getUnsafe); } + + @override + String toString() => bufferToLongString(this); + + String get _typeName => 'Buffer'; + + /// Converts a [Buffer] to a string like [Buffer.toString]. + /// + /// The string will be long and detailed, suitable for debugging, and even if + /// the dimensions of the buffer are large, the buffer will not be truncated, + /// which may result in a very long string for large buffers. + static String bufferToLongString(Buffer buffer) { + return _bufferToStringImpl(buffer); + } + + static String _bufferToStringImpl(Buffer buffer) { + final output = StringBuffer('${buffer._typeName} {\n'); + output.writeln(' width: ${buffer.width},'); + output.writeln(' height: ${buffer.height},'); + output.writeln(' format: ${buffer.format},'); + output.writeln(' data: (${buffer.length}) ['); + + for (var y = 0; y < buffer.height; y++) { + output.write(' '); + for (var x = 0; x < buffer.width; x++) { + output.write(buffer.format.describe(buffer.getUnsafe(Pos(x, y)))); + if (x < buffer.width - 1) { + output.write(', '); + } + } + output.writeln(','); + } + + output.writeln(' ]'); + output.write('}'); + return output.toString(); + } } abstract final class _Buffer with Buffer { diff --git a/lib/src/buffer/pixels_float.dart b/lib/src/buffer/pixels_float.dart index 67479ec..ca68639 100644 --- a/lib/src/buffer/pixels_float.dart +++ b/lib/src/buffer/pixels_float.dart @@ -86,4 +86,7 @@ final class Float32x4Pixels extends Pixels { @override final Float32x4List data; + + @override + String get _typeName => 'Float32x4Pixels'; } diff --git a/lib/src/buffer/pixels_int.dart b/lib/src/buffer/pixels_int.dart index c988c06..c56a3b3 100644 --- a/lib/src/buffer/pixels_int.dart +++ b/lib/src/buffer/pixels_int.dart @@ -86,4 +86,7 @@ final class IntPixels extends Pixels { @override final TypedDataList data; + + @override + String get _typeName => 'IntPixels'; } diff --git a/lib/src/codec.dart b/lib/src/codec.dart index 8a33066..31f18df 100644 --- a/lib/src/codec.dart +++ b/lib/src/codec.dart @@ -1 +1,2 @@ +export 'codec/netpbm.dart'; export 'codec/unpng.dart'; diff --git a/lib/src/codec/netpbm.dart b/lib/src/codec/netpbm.dart new file mode 100644 index 0000000..83f2284 --- /dev/null +++ b/lib/src/codec/netpbm.dart @@ -0,0 +1,592 @@ +/// Inspired from . +/// +/// See also: +/// - +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; +import 'package:pxl/pxl.dart'; + +/// Encodes pixel data as a portable pixmap (Netpbm) image format. +/// +/// {@macro pxl.netpbm_encoder.format} +/// +/// To specify the format explicitly, use [NetpbmBinaryEncoder.new]. +/// +/// {@category Output and Comparison} +const netpbmBinaryEncoder = NetpbmBinaryEncoder(); + +/// Encodes pixel data as a portable pixmap (Netpbm) image format using ASCII. +/// +/// {@macro pxl.netpbm_encoder.format} +/// +/// To specify the format explicitly, use [NetpbmAsciiEncoder.new]. +/// +/// {@category Output and Comparison} +const netpbmAsciiEncoder = NetpbmAsciiEncoder(); + +/// Decodes a portable pixmap (Netpbm) image format using binary to pixel data. +/// +/// {@macro pxl.netpbm_decoder.pixel_format} +/// +/// To specify the format explicitly, use [NetpbmBinaryDecoder.new]. +/// +/// {@category Output and Comparison} +const netpbmBinaryDecoder = NetpbmBinaryDecoder(); + +/// Decodes a portable pixmap (Netpbm) image format using ASCII to pixel data. +/// +/// {@macro pxl.netpbm_decoder.pixel_format} +/// +/// To specify the format explicitly, use [NetpbmAsciiDecoder.new]. +/// +/// {@category Output and Comparison} +const netpbmAsciiDecoder = NetpbmAsciiDecoder(); + +/// Formats supported by the Netpbm image format. +/// +/// {@category Output and Comparison} +enum NetpbmFormat { + /// Portable Bitmap (PBM) format. + /// + /// Each pixel representing one of two values, [PixelFormat.zero] and + /// [PixelFormat.max]. + bitmap, + + /// Portable Graymap (PGM) format. + /// + /// Each pixel representing a grayscale value. + graymap, + + /// Portable Pixmap (PPM) format. + /// + /// Each _three_ pixels representing RGB color values. + pixmap; +} + +/// Encodes pixel data as a portable pixmap (Netpbm) image format. +/// +/// {@category Output and Comparison} +abstract final class NetpbmEncoder extends Converter, T> { + /// @nodoc + const NetpbmEncoder({required this.format}); + + /// {@template pxl.netpbm_encoder.format} + /// The format of the Netpbm image. + /// + /// If omitted, the format is inferred from the pixel data: + /// - A format that implements [Grayscale] -> [NetpbmFormat.graymap]. + /// - A format that implements [Rgb] -> [NetpbmFormat.pixmap]. + /// - Otherwise, [NetpbmFormat.bitmap]. + /// {@endtemplate} + final NetpbmFormat? format; + + @override + T convert(Buffer input, {Iterable comments = const []}) { + final format = _getOrInferFormat(input); + final header = NetpbmHeader( + width: input.width, + height: input.height, + max: switch (format) { + NetpbmFormat.bitmap => null, + NetpbmFormat.graymap => gray8.maxGray, + NetpbmFormat.pixmap => rgb888.maxRed, + }, + format: format, + comments: comments, + ); + final Iterable pixels; + switch (format) { + case NetpbmFormat.bitmap: + pixels = input.data.map((value) { + return value == input.format.zero ? 0 : 1; + }); + case NetpbmFormat.graymap: + pixels = input.data.map((value) { + return gray8.convert(value, from: input.format); + }); + case NetpbmFormat.pixmap: + pixels = input.data.map((value) { + final rgb = rgb888.convert(value, from: input.format); + return [ + rgb888.getRed(rgb), + rgb888.getGreen(rgb), + rgb888.getBlue(rgb), + ]; + }).expand((p) => p); + } + return _convert(header, pixels); + } + + T _convert(NetpbmHeader header, Iterable pixels); + + @protected + NetpbmFormat _getOrInferFormat(Buffer input) { + if (input.width < 1) { + throw ArgumentError.value( + input.width, + 'input.width', + 'Must be greater than 0', + ); + } + if (input.height < 1) { + throw ArgumentError.value( + input.height, + 'input.height', + 'Must be greater than 0', + ); + } + if (format case final format?) { + return format; + } + return switch (input.format) { + Grayscale() => NetpbmFormat.graymap, + Rgb() => NetpbmFormat.pixmap, + _ => NetpbmFormat.bitmap, + }; + } +} + +/// Encodes pixel data in a portable pixmap (Netpbm) image format using ASCII. +/// +/// A singleton instance of this class is available as [netpbmAsciiEncoder]. +/// +/// {@category Output and Comparison} +final class NetpbmAsciiEncoder extends NetpbmEncoder { + /// Creates a new ASCII Netpbm encoder with the given [format]. + const NetpbmAsciiEncoder({super.format}); + + @override + String _convert(NetpbmHeader header, Iterable pixels) { + final output = StringBuffer(); + output.writeln('P${header.format.index + 1}'); + for (final comment in header.comments) { + output.writeln('# $comment'); + } + output.writeln('${header.width} ${header.height}'); + var padding = 1; + if (header.max case final max?) { + output.writeln(max); + padding = '$max'.length; + } + var i = 0; + final int newLine; + if (header.format == NetpbmFormat.pixmap) { + newLine = header.width * 3; + } else { + newLine = header.width; + } + for (final pixel in pixels) { + output.write('$pixel'.padLeft(padding)); + if (++i % newLine == 0) { + if (i < pixels.length) { + output.writeln(); + } + } else { + output.write(' '); + } + } + return output.toString(); + } +} + +/// Encodes pixel data in a portable pixmap (Netpbm) image format using binary. +/// +/// A singleton instance of this class is available as [netpbmBinaryEncoder]. +/// +/// {@category Output and Comparison} +final class NetpbmBinaryEncoder extends NetpbmEncoder { + /// Creates a new binary Netpbm encoder with the given [format]. + const NetpbmBinaryEncoder({super.format}); + + @override + Uint8List _convert(NetpbmHeader header, Iterable pixels) { + final output = BytesBuilder(copy: false); + output.add(utf8.encode('P${header.format.index + 4}\n')); + for (final comment in header.comments) { + output.add(utf8.encode('# $comment\n')); + } + output.add(utf8.encode('${header.width} ${header.height}\n')); + if (header.max case final max?) { + output.add(utf8.encode('$max\n')); + } + for (final pixel in pixels) { + output.addByte(pixel); + } + return output.toBytes(); + } +} + +/// Decodes a portable pixmap (Netpbm) image format to pixel data. +/// +/// {@category Output and Comparison} +abstract final class NetpbmDecoder extends Converter> { + /// @nodoc + const NetpbmDecoder({this.format = abgr8888}); + + /// {@template pxl.netpbm_encoder.pixel_format} + /// The pixel format of the decoded image. + /// + /// If omitted, the pixel format defauls to a fully opaque [abgr8888]. + /// {@endtemplate} + final PixelFormat format; + + /// Parses the header information from the Netpbm image. + /// + /// If the header is invalid or missing, a [FormatException] is thrown. + NetpbmHeader parseHeader(T input) { + final (header, error, offset) = _parseHeader(input); + if (header == null) { + throw FormatException(error, input); + } + return header; + } + + /// Parses the header information from the Netpbm image. + /// + /// If the header is invalid or missing, `null` is returned. + NetpbmHeader? tryParseHeader(T input) { + return _parseHeader(input).$1; + } + + /// Returns the length of the input. + @protected + int _lengthOf(T input); + + /// Returns the byte at the given index in the input. + @protected + int _byteAt(T input, int index); + + static bool _isLinebreak(int byte) => byte == 0x0A; + + static bool _isWhitespace(int byte) { + return byte == 0x20 || byte >= 0x09 && byte <= 0x0D; + } + + /// Reads the input until a test is met. + @nonVirtual + @protected + (String, int) _readUntil( + T input, + int start, [ + bool Function(int) test = _isLinebreak, + ]) { + final result = StringBuffer(); + for (; start < _lengthOf(input); start++) { + final byte = _byteAt(input, start); + if (test(byte)) { + start++; + break; + } + result.writeCharCode(byte); + } + return (result.toString(), start); + } + + /// Reads comments from the input. + /// + /// Returns the index after the comments. + @nonVirtual + @protected + int _readComments(T input, List output, int start) { + while (_byteAt(input, start) == 0x23) { + final (comment, next) = _readUntil(input, start + 1); + + // Check for and throw if EOF. + if (next == _lengthOf(input)) { + throw FormatException('Unexpected EOF', input, start); + } + + output.add(comment.substring(1).trim()); + start = next; + } + return start; + } + + /// Parses the header information from the Netpbm image. + /// + /// Returns the header, error message, and the offset after the header. + @protected + @nonVirtual + (NetpbmHeader? head, String error, int start) _parseHeader(T input) { + final NetpbmFormat format; + final int width; + final int height; + final comments = []; + int? max; + + // Read leading comments. + var start = _readComments(input, comments, 0); + + // Read the format. + final String magic; + (magic, start) = _readUntil(input, start); + switch (magic) { + case 'P1' when this is NetpbmAsciiDecoder: + format = NetpbmFormat.bitmap; + case 'P4' when this is NetpbmBinaryDecoder: + format = NetpbmFormat.bitmap; + case 'P2' when this is NetpbmAsciiDecoder: + format = NetpbmFormat.graymap; + case 'P5' when this is NetpbmBinaryDecoder: + format = NetpbmFormat.graymap; + case 'P3' when this is NetpbmAsciiDecoder: + format = NetpbmFormat.pixmap; + case 'P6' when this is NetpbmBinaryDecoder: + format = NetpbmFormat.pixmap; + default: + return (null, 'Invalid header format: $magic', start); + } + + // Read comments after the format. + start = _readComments(input, comments, start); + + // Read the width and height. + final String widthStr; + (widthStr, start) = _readUntil(input, start, _isWhitespace); + final String heightStr; + (heightStr, start) = _readUntil(input, start, _isWhitespace); + + // Parse the width and height. + if (int.tryParse(widthStr) case final value?) { + width = value; + } else { + return (null, 'Invalid width: $widthStr', start); + } + + if (int.tryParse(heightStr) case final value?) { + height = value; + } else { + return (null, 'Invalid height: $heightStr', start); + } + + // Read the maximum value if not a bitmap. + if (format != NetpbmFormat.bitmap) { + final String maxStr; + (maxStr, start) = _readUntil(input, start); + if (int.tryParse(maxStr) case final value?) { + max = value; + } else if (maxStr.isNotEmpty) { + return (null, 'Invalid max value: $maxStr', start); + } + } + + return ( + NetpbmHeader( + format: format, + width: width, + height: height, + max: max, + comments: comments, + ), + '', + start + ); + } + + /// Returns the pixels from the input starting at the given offset. + @protected + List _data(T input, int offset, {required bool bitmap}); + + @override + @nonVirtual + Buffer convert(T input) { + final (header, error, offset) = _parseHeader(input); + if (header == null) { + throw FormatException(error, input); + } + final data = _data( + input, + offset, + bitmap: header.format == NetpbmFormat.bitmap, + ); + final Iterable pixels; + switch (header.format) { + case NetpbmFormat.bitmap: + pixels = data.map((value) { + return value == 0x0 ? format.zero : format.max; + }); + case NetpbmFormat.graymap: + pixels = data.map((value) { + final gray = gray8.create(gray: value); + return format.convert(gray, from: gray8); + }); + case NetpbmFormat.pixmap: + if (data.length % 3 != 0) { + throw FormatException('Invalid pixel data', input, offset); + } + pixels = Iterable.generate(data.length ~/ 3, (i) { + final r = data[i * 3]; + final g = data[i * 3 + 1]; + final b = data[i * 3 + 2]; + final rgb = rgb888.create(red: r, green: g, blue: b); + return format.convert(rgb, from: rgb888); + }); + } + + final buffer = IntPixels(header.width, header.height, format: format); + buffer.data.setAll(0, pixels); + return buffer; + } +} + +/// Decodes a portable pixmap (Netpbm) image format using ASCII to pixel data. +/// +/// {@category Output and Comparison} +final class NetpbmAsciiDecoder extends NetpbmDecoder { + /// Creates a new ASCII Netpbm decoder with the given [format]. + const NetpbmAsciiDecoder({super.format}); + + @override + int _lengthOf(String input) => input.length; + + @override + int _byteAt(String input, int index) => input.codeUnitAt(index); + + @override + List _data(String input, int offset, {required bool bitmap}) { + final result = []; + + // For Bitmaps, whitespace is optional, that is: + // 0 0 1 + // 1 0 1 + // + // or: + // 001101 + // + // Are equally valid. + // + // For the other formats, whitespace separates the pixel values. + // 255 0 255 + + if (bitmap) { + for (var i = offset; i < input.length; i++) { + final byte = _byteAt(input, i); + if (byte == 0x30) { + result.add(0); + } else if (byte == 0x31) { + result.add(1); + } else if (NetpbmDecoder._isWhitespace(byte)) { + continue; + } else { + throw FormatException('Invalid pixel value', input, i); + } + } + } else { + while (offset < input.length) { + final (pixel, next) = _readUntil( + input, + offset, + NetpbmDecoder._isWhitespace, + ); + if (pixel.isEmpty) { + offset = next; + continue; + } + if (int.tryParse(pixel) case final value?) { + result.add(value); + } else { + throw FormatException('Invalid pixel value: $pixel', input, offset); + } + offset = next; + } + } + + return result; + } +} + +/// Decodes a portable pixmap (Netpbm) image format using binary to pixel data. +/// +/// {@category Output and Comparison} +final class NetpbmBinaryDecoder extends NetpbmDecoder { + /// Creates a new binary Netpbm decoder with the given [format]. + const NetpbmBinaryDecoder({super.format}); + + @override + int _lengthOf(Uint8List input) => input.length; + + @override + int _byteAt(Uint8List input, int index) => input[index]; + + @override + List _data(Uint8List input, int offset, {required bool bitmap}) { + return Uint8List.view(input.buffer, offset); + } +} + +/// Parsed header information from a Netpbm image. +@immutable +final class NetpbmHeader { + /// Creates a new Netpbm header with the given values. + NetpbmHeader({ + required this.format, + required this.width, + required this.height, + this.max, + Iterable comments = const [], + }) : comments = List.unmodifiable(comments); + + /// The format of the Netpbm image. + final NetpbmFormat format; + + /// The width of the image. + final int width; + + /// The height of the image. + final int height; + + /// The maximum value of a pixel in the image. + final int? max; + + /// Comments associated with the image. + final List comments; + + @override + bool operator ==(Object other) { + if (other is! NetpbmHeader) { + return false; + } + if (format != other.format) { + return false; + } + if (width != other.width) { + return false; + } + if (height != other.height) { + return false; + } + if (max != other.max) { + return false; + } + if (comments.length != other.comments.length) { + return false; + } + for (var i = 0; i < comments.length; i++) { + if (comments[i] != other.comments[i]) { + return false; + } + } + return true; + } + + @override + int get hashCode { + return Object.hash(format, width, height, max, Object.hashAll(comments)); + } + + @override + String toString() { + final buffer = StringBuffer('NetpbmHeader(\n'); + buffer.writeln(' format: $format,'); + buffer.writeln(' width: $width,'); + buffer.writeln(' height: $height,'); + buffer.writeln(' max: $max,'); + buffer.writeln(' comments: $comments,'); + buffer.write(')'); + return buffer.toString(); + } +} diff --git a/lib/src/codec/unpng.dart b/lib/src/codec/unpng.dart index 8766aa9..274616b 100644 --- a/lib/src/codec/unpng.dart +++ b/lib/src/codec/unpng.dart @@ -11,6 +11,14 @@ import 'package:pxl/pxl.dart'; /// Encodes a buffer of integer pixel data as an uncompressed RGBA PNG image. /// +/// {@category Output and Comparison} +const uncompressedPngEncoder = UncompressedPngEncoder._(); + +/// Encodes a buffer of pixel data as an uncompressed RGBA PNG image with 8-bit +/// color depth. +/// +/// A singleton instance of this class is available as [uncompressedPngEncoder]. +/// /// This encoder is intentionally minimal and does not support all features of /// the PNG format. It's primary purpose is to provide a zero-dependency way to /// visualize and persist pixel data in a standard format, i.e. for debugging @@ -40,15 +48,7 @@ import 'package:pxl/pxl.dart'; /// [3]: https://pub.dev/documentation/image/latest/image/PngEncoder-class.html /// /// {@category Output and Comparison} -const uncompressedPngEncoder = UncompressedPngEncoder._(); - -/// Encodes a buffer of pixel data as an uncompressed RGBA PNG image with 8-bit -/// color depth. -/// -/// A singleton instance of this class is available as [uncompressedPngEncoder]. -/// -/// {@category Output and Comparison} -final class UncompressedPngEncoder extends Converter, List> { +final class UncompressedPngEncoder extends Converter, Uint8List> { const UncompressedPngEncoder._(); /// The maximum resolution we support is 8192x8192. diff --git a/lib/src/format.dart b/lib/src/format.dart index 4ca4cb0..2d0a38e 100644 --- a/lib/src/format.dart +++ b/lib/src/format.dart @@ -1,3 +1,6 @@ +/// @docImport 'package:pxl/src/buffer.dart'; +library; + import 'dart:typed_data'; import 'package:meta/meta.dart'; @@ -198,6 +201,15 @@ abstract base mixin class PixelFormat { return fromFloatRgba(from.toFloatRgba(pixel)); } + /// Returns a human-readable description of the pixel. + /// + /// This is used in the default implementation of [Buffer.toString]. + /// + /// It is preferred that the result values are padded to a fixed width, and + /// that the result is a single line; for example, `0x12345678` or `[0.1, 0.2, + /// 0.3, 0.4]`. + String describe(P pixel) => pixel.toString(); + @override String toString() => name; } diff --git a/lib/src/format/float_rgba.dart b/lib/src/format/float_rgba.dart index 8f021bd..84d2056 100644 --- a/lib/src/format/float_rgba.dart +++ b/lib/src/format/float_rgba.dart @@ -118,4 +118,18 @@ final class FloatRgba extends Rgba { @override Float32x4 toFloatRgba(Float32x4 pixel) => pixel; + + @override + String describe(Float32x4 pixel) { + final output = StringBuffer('['); + output.write(pixel.x.toStringAsFixed(2)); + output.write(', '); + output.write(pixel.y.toStringAsFixed(2)); + output.write(', '); + output.write(pixel.z.toStringAsFixed(2)); + output.write(', '); + output.write(pixel.w.toStringAsFixed(2)); + output.write(']'); + return output.toString(); + } } diff --git a/lib/src/format/grayscale.dart b/lib/src/format/grayscale.dart index 505db29..8b79ebf 100644 --- a/lib/src/format/grayscale.dart +++ b/lib/src/format/grayscale.dart @@ -69,7 +69,7 @@ abstract final class _GrayInt extends PixelFormat double distance(int a, int b) => (a - b).abs().toDouble(); @override - double compare(int a, int b) => 1.0 - (a - b).abs() / maxGray.toDouble(); + double compare(int a, int b) => distance(a, b) / max; @override @nonVirtual @@ -85,4 +85,9 @@ abstract final class _GrayInt extends PixelFormat @override int get black => create(gray: minGray); + + @override + String describe(int pixel) { + return '0x${pixel.toRadixString(16).padLeft(max.bitLength ~/ 4, '0')}'; + } } diff --git a/lib/src/format/rgb.dart b/lib/src/format/rgb.dart index 97d83e2..92d6934 100644 --- a/lib/src/format/rgb.dart +++ b/lib/src/format/rgb.dart @@ -182,4 +182,9 @@ base mixin _Rgb8Int on Rgb { @override @nonVirtual int get maxBlue => 0xFF; + + @override + String describe(int pixel) { + return '0x${pixel.toRadixString(16).padLeft(max.bitLength ~/ 4, '0')}'; + } } diff --git a/test/codec/netpbm_ascii_test.dart b/test/codec/netpbm_ascii_test.dart new file mode 100644 index 0000000..c330f7b --- /dev/null +++ b/test/codec/netpbm_ascii_test.dart @@ -0,0 +1,181 @@ +import 'package:pxl/pxl.dart'; + +import '../src/prelude.dart'; + +void main() { + test('parses a header', () { + final header = netpbmAsciiDecoder.parseHeader('P1\n3 2\n'); + check(header).equals( + NetpbmHeader( + format: NetpbmFormat.bitmap, + width: 3, + height: 2, + ), + ); + }); + + test('parses a header with a maximum value', () { + final header = netpbmAsciiDecoder.parseHeader('P2\n3 2\n255\n'); + check(header).equals( + NetpbmHeader( + format: NetpbmFormat.graymap, + width: 3, + height: 2, + max: 255, + ), + ); + }); + + test('parses a header with a comment', () { + final header = netpbmAsciiDecoder.parseHeader( + 'P3\n' + '# comment\n' + '3 2\n', + ); + check(header).equals( + NetpbmHeader( + format: NetpbmFormat.pixmap, + width: 3, + height: 2, + comments: ['comment'], + ), + ); + }); + + test('width must be at least 1', () { + check( + () => netpbmAsciiEncoder.convert(_ZeroWidthBuffer()), + ).throws(); + }); + + test('height must be at least 1', () { + check( + () => netpbmAsciiEncoder.convert(_ZeroHeightBuffer()), + ).throws(); + }); + + test('explicit bitmap', () { + final pixels = IntPixels(3, 2); + pixels.set(Pos(0, 0), abgr8888.white); + pixels.set(Pos(1, 0), abgr8888.white); + pixels.set(Pos(2, 0), abgr8888.white); + + final encoded = const NetpbmAsciiEncoder( + format: NetpbmFormat.bitmap, + ).convert(pixels); + check(encoded).equals( + [ + 'P1', + '3 2', + '1 1 1', + '0 0 0', + ].join('\n'), + ); + + // Try decoding the encoded string. + final decoded = netpbmAsciiDecoder.convert(encoded); + check(decoded.format).equals(pixels.format); + check(decoded.compare(pixels).difference).equals(0.0); + }); + + test('implicit bitmap', () { + final monochrome = IndexedFormat.bits8( + [ + abgr8888.black, + abgr8888.white, + ], + format: abgr8888, + ); + final pixels = IntPixels(3, 2, format: monochrome); + pixels.set(Pos(0, 0), 1); + pixels.set(Pos(1, 0), 1); + pixels.set(Pos(2, 0), 1); + + final encoded = netpbmAsciiEncoder.convert(pixels); + check(encoded).equals( + [ + 'P1', + '3 2', + '1 1 1', + '0 0 0', + ].join('\n'), + ); + + // Try decoding the encoded string. + final decoded = NetpbmAsciiDecoder(format: monochrome).convert(encoded); + check(decoded.compare(pixels).difference).equals(0.0); + }); + + test('implicit gray8', () { + // Use multiple shades of gray to test the graymap format. + final pixels = IntPixels(3, 1, format: gray8); + pixels.set(Pos(0, 0), gray8.create(gray: 0)); + pixels.set(Pos(1, 0), gray8.create(gray: 128)); + pixels.set(Pos(2, 0), gray8.create(gray: 255)); + + final encoded = const NetpbmAsciiEncoder( + format: NetpbmFormat.graymap, + ).convert(pixels); + + check(encoded).equals( + [ + 'P2', + '3 1', + '255', + ' 0 128 255', + ].join('\n'), + ); + + // Try decoding the encoded string. + final decoded = NetpbmAsciiDecoder(format: gray8).convert(encoded); + check(decoded.format).equals(pixels.format); + check(decoded.compare(pixels).difference).equals(0.0); + }); + + test('implicit pixmap', () { + final pixels = IntPixels(3, 1); + pixels.set(Pos(0, 0), abgr8888.red); + pixels.set(Pos(1, 0), abgr8888.green); + pixels.set(Pos(2, 0), abgr8888.blue); + + final encoded = const NetpbmAsciiEncoder( + format: NetpbmFormat.pixmap, + ).convert(pixels); + + check(encoded).equals( + [ + 'P3', + '3 1', + '255', + '255 0 0 0 255 0 0 0 255', + ].join('\n'), + ); + + // Try decoding the encoded string. + final decoded = netpbmAsciiDecoder.convert(encoded); + check(decoded.format).equals(pixels.format); + check(decoded.compare(pixels).difference).equals(0.0); + }); +} + +final class _ZeroWidthBuffer extends Buffer { + @override + int get width => 0; + + @override + int get height => 1; + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _ZeroHeightBuffer extends Buffer { + @override + int get width => 1; + + @override + int get height => 0; + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/codec/netpbm_binary_test.dart b/test/codec/netpbm_binary_test.dart new file mode 100644 index 0000000..371c05f --- /dev/null +++ b/test/codec/netpbm_binary_test.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:pxl/pxl.dart'; + +import '../src/prelude.dart'; + +void main() { + test('parses a header', () { + final header = netpbmBinaryDecoder.parseHeader( + utf8.encode('P4\n3 2\n'), + ); + check(header).equals( + NetpbmHeader( + format: NetpbmFormat.bitmap, + width: 3, + height: 2, + ), + ); + }); + + test('parses a header with a maximum value', () { + final header = netpbmBinaryDecoder.parseHeader( + utf8.encode('P5\n3 2\n255\n'), + ); + check(header).equals( + NetpbmHeader( + format: NetpbmFormat.graymap, + width: 3, + height: 2, + max: 255, + ), + ); + }); + + test('parses a header with a comment', () { + final header = netpbmBinaryDecoder.parseHeader( + utf8.encode( + 'P6\n' + '# comment\n' + '3 2\n', + ), + ); + check(header).equals( + NetpbmHeader( + format: NetpbmFormat.pixmap, + width: 3, + height: 2, + comments: ['comment'], + ), + ); + }); + + test('width must be at least 1', () { + check( + () => netpbmBinaryEncoder.convert(_ZeroWidthBuffer()), + ).throws(); + }); + + test('height must be at least 1', () { + check( + () => netpbmBinaryEncoder.convert(_ZeroHeightBuffer()), + ).throws(); + }); + + test('explicit bitmap', () { + final pixels = IntPixels(3, 2); + pixels.set(Pos(0, 0), abgr8888.white); + pixels.set(Pos(1, 0), abgr8888.white); + pixels.set(Pos(2, 0), abgr8888.white); + + final encoded = const NetpbmBinaryEncoder( + format: NetpbmFormat.bitmap, + ).convert(pixels); + check(encoded).deepEquals( + Uint8List.fromList([ + ...utf8.encode('P4\n3 2\n'), + 1, 1, 1, // + 0, 0, 0, // + ]), + ); + + // Try decoding the encoded string. + final decoded = netpbmBinaryDecoder.convert(encoded); + check(decoded.format).equals(pixels.format); + check(decoded.compare(pixels).difference).equals(0.0); + }); + + test('implicit bitmap', () { + final monochrome = IndexedFormat.bits8( + [ + abgr8888.black, + abgr8888.white, + ], + format: abgr8888, + ); + final pixels = IntPixels(3, 2, format: monochrome); + pixels.set(Pos(0, 0), 1); + pixels.set(Pos(1, 0), 1); + pixels.set(Pos(2, 0), 1); + + final encoded = netpbmBinaryEncoder.convert(pixels); + check(encoded).deepEquals( + Uint8List.fromList([ + ...utf8.encode('P4\n3 2\n'), + 1, 1, 1, // + 0, 0, 0, // + ]), + ); + + // Try decoding the encoded string. + final decoded = NetpbmBinaryDecoder(format: monochrome).convert(encoded); + check(decoded.compare(pixels).difference).equals(0.0); + }); + + test('implicit gray8', () { + // Use multiple shades of gray to test the graymap format. + final pixels = IntPixels(3, 1, format: gray8); + pixels.set(Pos(0, 0), gray8.create(gray: 0)); + pixels.set(Pos(1, 0), gray8.create(gray: 128)); + pixels.set(Pos(2, 0), gray8.create(gray: 255)); + + final encoded = const NetpbmBinaryEncoder( + format: NetpbmFormat.graymap, + ).convert(pixels); + + check(encoded).deepEquals( + Uint8List.fromList([ + ...utf8.encode('P5\n3 1\n255\n'), + 0, 128, 255, // + ]), + ); + + // Try decoding the encoded string. + final decoded = NetpbmBinaryDecoder(format: gray8).convert(encoded); + check(decoded.format).equals(pixels.format); + check(decoded.compare(pixels).difference).equals(0.0); + }); + + test('implicit pixmap', () { + final pixels = IntPixels(3, 1); + pixels.set(Pos(0, 0), abgr8888.red); + pixels.set(Pos(1, 0), abgr8888.green); + pixels.set(Pos(2, 0), abgr8888.blue); + + final encoded = const NetpbmBinaryEncoder( + format: NetpbmFormat.pixmap, + ).convert(pixels); + + check(encoded).deepEquals( + Uint8List.fromList([ + ...utf8.encode('P6\n3 1\n255\n'), + 255, 0, 0, // + 0, 255, 0, // + 0, 0, 255, // + ]), + ); + + // Try decoding the encoded string. + final decoded = netpbmBinaryDecoder.convert(encoded); + check(decoded.format).equals(pixels.format); + check(decoded.compare(pixels).difference).equals(0.0); + }); +} + +final class _ZeroWidthBuffer extends Buffer { + @override + int get width => 0; + + @override + int get height => 1; + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _ZeroHeightBuffer extends Buffer { + @override + int get width => 1; + + @override + int get height => 0; + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/unpng_golden_test.dart b/test/codec/unpng_golden_test.dart similarity index 98% rename from test/unpng_golden_test.dart rename to test/codec/unpng_golden_test.dart index 7212f8b..2749b6c 100644 --- a/test/unpng_golden_test.dart +++ b/test/codec/unpng_golden_test.dart @@ -5,7 +5,7 @@ import 'dart:io' as io; import 'dart:typed_data'; import 'package:path/path.dart' as p; import 'package:pxl/pxl.dart'; -import 'src/prelude.dart'; +import '../src/prelude.dart'; final _updateGoldens = () { return switch (io.Platform.environment['UPDATE_GOLDENS']?.toUpperCase()) { diff --git a/test/unpng_test.dart b/test/codec/unpng_test.dart similarity index 97% rename from test/unpng_test.dart rename to test/codec/unpng_test.dart index 664a128..af1f661 100644 --- a/test/unpng_test.dart +++ b/test/codec/unpng_test.dart @@ -1,6 +1,6 @@ import 'package:pxl/pxl.dart'; -import 'src/prelude.dart'; +import '../src/prelude.dart'; void main() { test('width must be at least 1', () { diff --git a/test/format/gray8_test.dart b/test/format/gray8_test.dart index 10d34a6..6ccc1d3 100644 --- a/test/format/gray8_test.dart +++ b/test/format/gray8_test.dart @@ -60,10 +60,10 @@ void main() { }); test('compare', () { - check(gray8.compare(0, 0)).equals(1.0); - check(gray8.compare(0, 29)).equals(0.8862745098039215); - check(gray8.compare(29, 0)).equals(0.8862745098039215); - check(gray8.compare(29, 29)).equals(1.0); + check(gray8.compare(0, 0)).equals(0.0); + check(gray8.compare(0, 29)).equals(0.11372549019607843); + check(gray8.compare(29, 0)).equals(0.11372549019607843); + check(gray8.compare(29, 29)).equals(0.0); }); test('minGray', () { diff --git a/test/pixels_test.dart b/test/pixels_test.dart index 16e08ea..98edf37 100644 --- a/test/pixels_test.dart +++ b/test/pixels_test.dart @@ -70,6 +70,24 @@ void main() { pixels.set(Pos(0, 0), 0xFFFFFFFF); check(pixels.get(Pos(0, 0))).equals(0xFFFFFFFF); }); + + test('toString() is written in full with padding', () { + final empty = IntPixels(5, 3); + check(empty.toString()).equals( + [ + 'IntPixels {', + ' width: 5,', + ' height: 3,', + ' format: ABGR8888,', + ' data: (15) [', + ' 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000,', + ' 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000,', + ' 0x00000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000,', + ' ]', + '}', + ].join('\n'), + ); + }); }); group('Float32x4Pixels', () { @@ -138,6 +156,24 @@ void main() { pixels.set(Pos(0, 0), Float32x4(1.0, 1.0, 1.0, 1.0)); check(pixels.get(Pos(0, 0))).equals(Float32x4(1.0, 1.0, 1.0, 1.0)); }); + + test('toString() is written in full with padding', () { + final empty = Float32x4Pixels(5, 3); + check(empty.toString()).equals( + [ + 'Float32x4Pixels {', + ' width: 5,', + ' height: 3,', + ' format: FLOAT_RGBA,', + ' data: (15) [', + ' [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00],', + ' [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00],', + ' [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00], [0.00, 0.00, 0.00, 0.00],', + ' ]', + '}', + ].join('\n'), + ); + }); }); test('fill', () {