From 0f159138e47339b61d6195bf2c6dfde5b6e68972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Wed, 17 Aug 2022 15:09:16 +0100 Subject: [PATCH 1/7] Add a PPM Image Writer --- .../minart/graphics/image/Image.scala | 2 +- .../minart/graphics/image/ImageWriter.scala | 17 +++++ .../graphics/image/helpers/ByteWriter.scala | 73 +++++++++++++++++++ .../minart/graphics/image/ppm/Header.scala | 8 ++ .../graphics/image/ppm/PpmImageFormat.scala | 23 ++++++ .../image/{ => ppm}/PpmImageLoader.scala | 56 ++++++-------- .../graphics/image/ppm/PpmImageWriter.scala | 48 ++++++++++++ ...{ImageSpec.scala => ImageLoaderSpec.scala} | 2 +- .../graphics/image/ImageWriterSpec.scala | 23 ++++++ 9 files changed, 217 insertions(+), 35 deletions(-) create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteWriter.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/Header.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageFormat.scala rename image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/{ => ppm}/PpmImageLoader.scala (78%) create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageWriter.scala rename image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/{ImageSpec.scala => ImageLoaderSpec.scala} (98%) create mode 100644 image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala index 489ce9bf..8375ea11 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala @@ -27,7 +27,7 @@ object Image { /** Loads an image in the PPM format. */ def loadPpmImage(resource: Resource): Try[RamSurface] = - loadImage(PpmImageLoader.defaultLoader, resource) + loadImage(ppm.PpmImageFormat.defaultFormat, resource) /** Loads an image in the BMP format. */ diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala new file mode 100644 index 00000000..dc017fe2 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala @@ -0,0 +1,17 @@ +package eu.joaocosta.minart.graphics.image + +import java.io.OutputStream + +import eu.joaocosta.minart.graphics._ + +/** Image writer with a low-level implementation on how to store an image. + */ +trait ImageWriter { + + /** Stores a surface to an OutputStream. + * + * @param surface Surface to store + * @param os OutputStream where to store the data + */ + def storeImage(surface: Surface, os: OutputStream): Either[String, Unit] +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteWriter.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteWriter.scala new file mode 100644 index 00000000..d6ed20b3 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteWriter.scala @@ -0,0 +1,73 @@ +package eu.joaocosta.minart.graphics.image.helpers + +import java.io.OutputStream + +import scala.collection.compat.immutable.LazyList + +/** Helper methods to write binary data to an output stream. + */ +trait ByteWriter[F[_]] { + type ByteStream = F[Array[Byte]] + type ByteStreamState[E] = State[ByteStream, E, Unit] + + /** Writes a ByteStream to a output stream */ + def toOutputStream[E](data: ByteStreamState[E], os: OutputStream): Either[E, Unit] + + /** Empty state */ + def emptyStream: ByteStreamState[Nothing] = State.pure(()) + + /** Appends this byte stream to the current accumulator */ + def append(stream: ByteStream): ByteStreamState[Nothing] + + /** Adds a sequence of bytes to the tail of the byte stream */ + def writeBytes(bytes: Seq[Int]): ByteStreamState[String] + + /** Write 1 Byte */ + def writeByte(byte: Int): ByteStreamState[String] = writeBytes(Seq(byte)) + + /** Writes a String */ + def writeString(string: String): ByteStreamState[String] = + writeBytes(string.map(_.toInt)) + + /** Writes a String Line */ + def writeStringLn(string: String, delimiter: String = "\n"): ByteStreamState[String] = + writeString(string + delimiter) + + /** Writes a Integer in N Bytes (Little Endian) */ + def writeLENumber(value: Int, bytes: Int): ByteStreamState[String] = + writeBytes((0 until bytes).map { idx => (value >> (idx * 8)) & 0x000000ff }) + + /** Writes a Integer in N Bytes (Big Endian) */ + def writeBENumber(value: Int, bytes: Int): ByteStreamState[String] = + writeBytes((0 until bytes).reverse.map { idx => (value >> (idx * 8)) & 0x000000ff }) +} + +object ByteWriter { + object LazyListByteWriter extends ByteWriter[LazyList] { + def toOutputStream[E](data: ByteStreamState[E], os: OutputStream): Either[E, Unit] = + data.run(LazyList.empty[Array[Byte]]).map { case (s, _) => + s.foreach(bytes => os.write(bytes)) + } + + def append(stream: ByteStream): ByteStreamState[Nothing] = + State.modify[ByteStream](s => s ++ stream) + + def writeBytes(bytes: Seq[Int]): ByteStreamState[String] = + if (bytes.forall(b => b >= 0 && b <= 255)) append(LazyList(bytes.map(_.toByte).toArray)) + else State.error(s"Sequence $bytes contains invalid bytes") + } + + object IteratorByteWriter extends ByteWriter[Iterator] { + def toOutputStream[E](data: ByteStreamState[E], os: OutputStream): Either[E, Unit] = + data.run(Iterator.empty[Array[Byte]]).map { case (s, _) => + s.foreach(bytes => os.write(bytes)) + } + + def append(stream: ByteStream): ByteStreamState[Nothing] = + State.modify[ByteStream](s => s ++ stream) + + def writeBytes(bytes: Seq[Int]): ByteStreamState[String] = + if (bytes.forall(b => b >= 0 && b <= 255)) append(Iterator(bytes.map(_.toByte).toArray)) + else State.error(s"Sequence $bytes contains invalid bytes") + } +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/Header.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/Header.scala new file mode 100644 index 00000000..a822ef3b --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/Header.scala @@ -0,0 +1,8 @@ +package eu.joaocosta.minart.graphics.image.ppm + +final case class Header( + magic: String, + width: Int, + height: Int, + colorRange: Int +) diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageFormat.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageFormat.scala new file mode 100644 index 00000000..5cafa0ce --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageFormat.scala @@ -0,0 +1,23 @@ +package eu.joaocosta.minart.graphics.image.ppm + +import java.io.InputStream + +import scala.annotation.tailrec + +import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.graphics.image.helpers._ +import eu.joaocosta.minart.graphics.image.ppm + +/** Image loader and writer for PGM/PPM files. + * + * Supports P2, P3, P5 and P6 PGM/PPM files with a 8 bit color range. + */ +final class PpmImageFormat[F[_]](val byteReader: ByteReader[F], val byteWriter: ByteWriter[F]) + extends PpmImageLoader[F] + with PpmImageWriter[F] + +object PpmImageFormat { + val defaultFormat = new PpmImageFormat[Iterator](ByteReader.IteratorByteReader, ByteWriter.IteratorByteWriter) + + val supportedFormats = Set("P2", "P3", "P5", "P6") +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/PpmImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageLoader.scala similarity index 78% rename from image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/PpmImageLoader.scala rename to image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageLoader.scala index 92c5390c..f40796e5 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/PpmImageLoader.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageLoader.scala @@ -1,17 +1,21 @@ -package eu.joaocosta.minart.graphics.image +package eu.joaocosta.minart.graphics.image.ppm import java.io.InputStream import scala.annotation.tailrec import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.graphics.image._ import eu.joaocosta.minart.graphics.image.helpers._ /** Image loader for PGM/PPM files. * * Supports P2, P3, P5 and P6 PGM/PPM files with a 8 bit color range. */ -final class PpmImageLoader[F[_]](byteReader: ByteReader[F]) extends ImageLoader { +trait PpmImageLoader[F[_]] extends ImageLoader { + val byteReader: ByteReader[F] + + import PpmImageFormat._ import PpmImageLoader._ private val byteStringOps = new ByteStringOps(byteReader) import byteReader._ @@ -65,9 +69,25 @@ final class PpmImageLoader[F[_]](byteReader: ByteReader[F]) extends ImageLoader } } + def loadHeader(bytes: F[Int]): ParseResult[Header] = { + val byteStringOps = new PpmImageLoader.ByteStringOps(byteReader) + import byteStringOps._ + ( + for { + magic <- readNextString.validate(PpmImageFormat.supportedFormats, m => s"Unsupported format: $m") + width <- parseNextInt(s"Invalid width") + height <- parseNextInt(s"Invalid height") + colorRange <- parseNextInt(s"Invalid color range").validate( + _ == 255, + range => s"Unsupported color range: $range" + ) + } yield Header(magic, width, height, colorRange) + ).run(bytes) + } + def loadImage(is: InputStream): Either[String, RamSurface] = { val bytes = fromInputStream(is) - Header.fromBytes(bytes)(byteReader).right.flatMap { case (data, header) => + loadHeader(bytes).right.flatMap { case (data, header) => val numPixels = header.width * header.height val pixels = header.magic match { case "P2" => @@ -90,35 +110,6 @@ final class PpmImageLoader[F[_]](byteReader: ByteReader[F]) extends ImageLoader } object PpmImageLoader { - val defaultLoader = new PpmImageLoader[Iterator](ByteReader.IteratorByteReader) - - val supportedFormats = Set("P2", "P3", "P5", "P6") - - final case class Header( - magic: String, - width: Int, - height: Int, - colorRange: Int - ) - - object Header { - def fromBytes[F[_]](bytes: F[Int])(byteReader: ByteReader[F]): byteReader.ParseResult[Header] = { - val byteStringOps = new PpmImageLoader.ByteStringOps(byteReader) - import byteStringOps._ - ( - for { - magic <- readNextString.validate(supportedFormats, m => s"Unsupported format: $m") - width <- parseNextInt(s"Invalid width") - height <- parseNextInt(s"Invalid height") - colorRange <- parseNextInt(s"Invalid color range").validate( - _ == 255, - range => s"Unsupported color range: $range" - ) - } yield Header(magic, width, height, colorRange) - ).run(bytes) - } - } - private final class ByteStringOps[F[_]](val byteReader: ByteReader[F]) { import byteReader._ private val newLine = '\n'.toInt @@ -163,6 +154,5 @@ object PpmImageLoader { } State.fromEither(intEither) } - } } diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageWriter.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageWriter.scala new file mode 100644 index 00000000..53345d1a --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageWriter.scala @@ -0,0 +1,48 @@ +package eu.joaocosta.minart.graphics.image.ppm + +import java.io.OutputStream + +import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.graphics.image._ +import eu.joaocosta.minart.graphics.image.helpers._ + +/** Image writer for PPM files. + * + * Stores data as P6 PPM files with a 8 bit color range. + */ +trait PpmImageWriter[F[_]] extends ImageWriter { + val byteWriter: ByteWriter[F] + + import byteWriter._ + + private def storeBinaryRgbPixel(color: Color): ByteStreamState[String] = + writeBytes(List(color.r, color.g, color.b)) + + private def storePixels( + storeColor: Color => ByteStreamState[String], + surface: Surface, + currentPixel: Int = 0, + acc: ByteStreamState[String] = emptyStream + ): ByteStreamState[String] = { + if (currentPixel >= surface.width * surface.height) acc + else { + val color = surface.unsafeGetPixel(currentPixel % surface.width, currentPixel / surface.width) + storePixels(storeColor, surface, currentPixel + 1, acc.flatMap(_ => storeColor(color))) + } + } + + private def storeHeader(surface: Surface): ByteStreamState[String] = + for { + _ <- writeStringLn("P6") + _ <- writeStringLn(s"${surface.width} ${surface.height}") + _ <- writeStringLn("255") + } yield () + + def storeImage(surface: Surface, os: OutputStream): Either[String, Unit] = { + val state = for { + _ <- storeHeader(surface) + _ <- storePixels(storeBinaryRgbPixel, surface) + } yield () + toOutputStream(state, os) + } +} diff --git a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageSpec.scala b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageLoaderSpec.scala similarity index 98% rename from image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageSpec.scala rename to image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageLoaderSpec.scala index b19f1f95..fe55bf88 100644 --- a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageSpec.scala +++ b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageLoaderSpec.scala @@ -5,7 +5,7 @@ import verify._ import eu.joaocosta.minart.backend.defaults._ import eu.joaocosta.minart.runtime._ -object ImageSpec extends BasicTestSuite { +object ImageLoaderSpec extends BasicTestSuite { // Can't load resources in JS tests if (Platform() != Platform.JS) { diff --git a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala new file mode 100644 index 00000000..bc155eb3 --- /dev/null +++ b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala @@ -0,0 +1,23 @@ +package eu.joaocosta.minart.graphics.image + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} + +import verify._ + +import eu.joaocosta.minart.backend.defaults._ +import eu.joaocosta.minart.runtime._ + +object ImageWriterSpec extends BasicTestSuite { + + // Can't load resources in JS tests + if (Platform() != Platform.JS) { + test("Write a PPM image") { + val originalImage = Image.loadPpmImage(Resource("scala.ppm")).get + val os = new ByteArrayOutputStream() + ppm.PpmImageFormat.defaultFormat.storeImage(originalImage, os) + val is = new ByteArrayInputStream(os.toByteArray) + val newImage = ppm.PpmImageFormat.defaultFormat.loadImage(is).toOption.get + assert(newImage.getPixels().map(_.toVector) == originalImage.getPixels().map(_.toVector)) + } + } +} From a00c1a80838553fdf19458d0fe9e3b318eeec4c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Wed, 17 Aug 2022 15:38:20 +0100 Subject: [PATCH 2/7] Add a BmpImageWriter --- .../graphics/image/BmpImageLoader.scala | 125 ------------------ .../minart/graphics/image/Image.scala | 2 +- .../graphics/image/bmp/BmpImageFormat.scala | 20 +++ .../graphics/image/bmp/BmpImageLoader.scala | 109 +++++++++++++++ .../graphics/image/bmp/BmpImageWriter.scala | 65 +++++++++ .../minart/graphics/image/bmp/Header.scala | 10 ++ .../graphics/image/ppm/PpmImageFormat.scala | 3 - .../graphics/image/ppm/PpmImageWriter.scala | 7 +- .../graphics/image/ImageWriterSpec.scala | 9 ++ 9 files changed, 220 insertions(+), 130 deletions(-) delete mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/BmpImageLoader.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageFormat.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageLoader.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageWriter.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/Header.scala diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/BmpImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/BmpImageLoader.scala deleted file mode 100644 index 07e5379d..00000000 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/BmpImageLoader.scala +++ /dev/null @@ -1,125 +0,0 @@ -package eu.joaocosta.minart.graphics.image - -import java.io.InputStream - -import scala.annotation.tailrec - -import eu.joaocosta.minart.graphics._ -import eu.joaocosta.minart.graphics.image.helpers._ - -/** Image loader for BMP files. - * - * Supports uncompressed 24/32bit Windows BMPs. - */ -final class BmpImageLoader[F[_]](byteReader: ByteReader[F]) extends ImageLoader { - import BmpImageLoader._ - import byteReader._ - - private val loadRgbPixel: ParseState[String, Color] = - readBytes(3) - .collect( - { case bytes if bytes.size == 3 => Color(bytes(2), bytes(1), bytes(0)) }, - _ => "Not enough data to read RGB pixel" - ) - - private val loadRgbaPixel: ParseState[String, Color] = - readBytes(4) - .collect( - { case bytes if bytes.size == 4 => Color(bytes(2), bytes(1), bytes(0)) }, - _ => "Not enough data to read RGBA pixel" - ) - - @tailrec - private def loadPixels( - loadColor: ParseState[String, Color], - data: F[Int], - remainingPixels: Int, - acc: List[Color] = Nil - ): ParseResult[List[Color]] = { - if (isEmpty(data) || remainingPixels == 0) Right(data -> acc.reverse) - else { - loadColor.run(data) match { - case Left(error) => Left(error) - case Right((remaining, color)) => loadPixels(loadColor, remaining, remainingPixels - 1, color :: acc) - } - } - } - - def loadImage(is: InputStream): Either[String, RamSurface] = { - val bytes = fromInputStream(is) - Header.fromBytes(bytes)(byteReader).right.flatMap { case (data, header) => - val numPixels = header.width * header.height - val pixels = header.bitsPerPixel match { - case 24 => - loadPixels(loadRgbPixel, data, numPixels) - case 32 => - loadPixels(loadRgbaPixel, data, numPixels) - case bpp => - Left(s"Invalid bits per pixel: $bpp") - } - pixels.right.flatMap { case (_, flatPixels) => - if (flatPixels.size != numPixels) Left(s"Invalid number of pixels: Got ${flatPixels.size}, expected $numPixels") - else Right(new RamSurface(flatPixels.sliding(header.width, header.width).toSeq.reverse)) - } - } - } -} - -object BmpImageLoader { - val defaultLoader = new BmpImageLoader[Iterator](ByteReader.IteratorByteReader) - - val supportedFormats = Set("BM") - - final case class Header( - magic: String, - size: Int, - offset: Int, - width: Int, - height: Int, - bitsPerPixel: Int - ) - object Header { - def fromBytes[F[_]](bytes: F[Int])(byteReader: ByteReader[F]): byteReader.ParseResult[Header] = { - import byteReader._ - (for { - magic <- readString(2).validate( - supportedFormats, - m => s"Unsupported format: $m. Only windows BMPs are supported" - ) - size <- readLENumber(4) - _ <- skipBytes(4) - offset <- readLENumber(4) - dibHeaderSize <- readLENumber(4).validate( - dib => dib >= 40 && dib <= 124, - dib => s"Unsupported DIB header size: $dib" - ) - width <- readLENumber(4) - height <- readLENumber(4) - colorPlanes <- readLENumber(2).validate( - _ == 1, - planes => s"Invalid number of color planes (must be 1): $planes" - ) - bitsPerPixel <- readLENumber(2).validate( - Set(24, 32), - bpp => s"Unsupported bits per pixel (must be 24 or 32): $bpp" - ) - compressionMethod <- readLENumber(4).validate( - c => c == 0 || c == 3 || c == 6, - _ => "Compression is not supported" - ) - loadColorMask = compressionMethod == 3 || compressionMethod == 6 - _ <- if (loadColorMask) skipBytes(20) else noop - redMask <- if (loadColorMask) readLENumber(4) else State.pure[F[Int], Int](0x00ff0000) - greenMask <- if (loadColorMask) readLENumber(4) else State.pure[F[Int], Int](0x0000ff00) - blueMask <- if (loadColorMask) readLENumber(4) else State.pure[F[Int], Int](0x000000ff) - _ <- if (loadColorMask) skipBytes(4) else noop // Skip alpha mask (or color space) - _ <- State.check( - redMask == 0x00ff0000 && greenMask == 0x0000ff00 && blueMask == 0x000000ff, - "Unsupported color format (must be either RGB or ARGB)" - ) - header = Header(magic, size, offset, width, height, bitsPerPixel) - _ <- skipBytes(offset - (if (loadColorMask) 70 else 34)) - } yield header).run(bytes) - } - } -} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala index 8375ea11..e92fca4d 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala @@ -32,7 +32,7 @@ object Image { /** Loads an image in the BMP format. */ def loadBmpImage(resource: Resource): Try[RamSurface] = - loadImage(BmpImageLoader.defaultLoader, resource) + loadImage(bmp.BmpImageFormat.defaultFormat, resource) /** Loads an image in the QOI format. */ diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageFormat.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageFormat.scala new file mode 100644 index 00000000..70e908be --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageFormat.scala @@ -0,0 +1,20 @@ +package eu.joaocosta.minart.graphics.image.bmp + +import java.io.InputStream + +import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.graphics.image.helpers._ + +/** Image loader and writer for BMP files. + * + * Supports uncompressed 24/32bit Windows BMPs. + */ +final class BmpImageFormat[F[_]](val byteReader: ByteReader[F], val byteWriter: ByteWriter[F]) + extends BmpImageLoader[F] + with BmpImageWriter[F] + +object BmpImageFormat { + val defaultFormat = new BmpImageFormat[Iterator](ByteReader.IteratorByteReader, ByteWriter.IteratorByteWriter) + + val supportedFormats = Set("BM") +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageLoader.scala new file mode 100644 index 00000000..73e82593 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageLoader.scala @@ -0,0 +1,109 @@ +package eu.joaocosta.minart.graphics.image.bmp + +import java.io.InputStream + +import scala.annotation.tailrec + +import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.graphics.image._ +import eu.joaocosta.minart.graphics.image.helpers._ + +/** Image loader for BMP files. + * + * Supports uncompressed 24/32bit Windows BMPs. + */ +trait BmpImageLoader[F[_]] extends ImageLoader { + val byteReader: ByteReader[F] + import byteReader._ + + private val loadRgbPixel: ParseState[String, Color] = + readBytes(3) + .collect( + { case bytes if bytes.size == 3 => Color(bytes(2), bytes(1), bytes(0)) }, + _ => "Not enough data to read RGB pixel" + ) + + private val loadRgbaPixel: ParseState[String, Color] = + readBytes(4) + .collect( + { case bytes if bytes.size == 4 => Color(bytes(2), bytes(1), bytes(0)) }, + _ => "Not enough data to read RGBA pixel" + ) + + @tailrec + private def loadPixels( + loadColor: ParseState[String, Color], + data: F[Int], + remainingPixels: Int, + acc: List[Color] = Nil + ): ParseResult[List[Color]] = { + if (isEmpty(data) || remainingPixels == 0) Right(data -> acc.reverse) + else { + loadColor.run(data) match { + case Left(error) => Left(error) + case Right((remaining, color)) => loadPixels(loadColor, remaining, remainingPixels - 1, color :: acc) + } + } + } + + def loadHeader(bytes: F[Int]): ParseResult[Header] = { + (for { + magic <- readString(2).validate( + BmpImageFormat.supportedFormats, + m => s"Unsupported format: $m. Only windows BMPs are supported" + ) + size <- readLENumber(4) + _ <- skipBytes(4) + offset <- readLENumber(4) + dibHeaderSize <- readLENumber(4).validate( + dib => dib >= 40 && dib <= 124, + dib => s"Unsupported DIB header size: $dib" + ) + width <- readLENumber(4) + height <- readLENumber(4) + colorPlanes <- readLENumber(2).validate( + _ == 1, + planes => s"Invalid number of color planes (must be 1): $planes" + ) + bitsPerPixel <- readLENumber(2).validate( + Set(24, 32), + bpp => s"Unsupported bits per pixel (must be 24 or 32): $bpp" + ) + compressionMethod <- readLENumber(4).validate( + c => c == 0 || c == 3 || c == 6, + _ => "Compression is not supported" + ) + loadColorMask = compressionMethod == 3 || compressionMethod == 6 + _ <- if (loadColorMask) skipBytes(20) else noop + redMask <- if (loadColorMask) readLENumber(4) else State.pure[F[Int], Int](0x00ff0000) + greenMask <- if (loadColorMask) readLENumber(4) else State.pure[F[Int], Int](0x0000ff00) + blueMask <- if (loadColorMask) readLENumber(4) else State.pure[F[Int], Int](0x000000ff) + _ <- if (loadColorMask) skipBytes(4) else noop // Skip alpha mask (or color space) + _ <- State.check( + redMask == 0x00ff0000 && greenMask == 0x0000ff00 && blueMask == 0x000000ff, + "Unsupported color format (must be either RGB or ARGB)" + ) + header = Header(magic, size, offset, width, height, bitsPerPixel) + _ <- skipBytes(offset - (if (loadColorMask) 70 else 34)) + } yield header).run(bytes) + } + + def loadImage(is: InputStream): Either[String, RamSurface] = { + val bytes = fromInputStream(is) + loadHeader(bytes).right.flatMap { case (data, header) => + val numPixels = header.width * header.height + val pixels = header.bitsPerPixel match { + case 24 => + loadPixels(loadRgbPixel, data, numPixels) + case 32 => + loadPixels(loadRgbaPixel, data, numPixels) + case bpp => + Left(s"Invalid bits per pixel: $bpp") + } + pixels.right.flatMap { case (_, flatPixels) => + if (flatPixels.size != numPixels) Left(s"Invalid number of pixels: Got ${flatPixels.size}, expected $numPixels") + else Right(new RamSurface(flatPixels.sliding(header.width, header.width).toSeq.reverse)) + } + } + } +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageWriter.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageWriter.scala new file mode 100644 index 00000000..707b32aa --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/BmpImageWriter.scala @@ -0,0 +1,65 @@ +package eu.joaocosta.minart.graphics.image.bmp + +import java.io.OutputStream + +import scala.annotation.tailrec + +import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.graphics.image._ +import eu.joaocosta.minart.graphics.image.helpers._ + +/** Image writer for BMP files. + * + * Stores data as uncompressed 24bit Windows BMPs. + */ +trait BmpImageWriter[F[_]] extends ImageWriter { + val byteWriter: ByteWriter[F] + import byteWriter._ + + private def storeBgrPixel(color: Color): ByteStreamState[String] = + writeBytes(List(color.b, color.g, color.r)) + + @tailrec + private def storePixels( + storeColor: Color => ByteStreamState[String], + surface: Surface, + currentPixel: Int = 0, + acc: ByteStreamState[String] = emptyStream + ): ByteStreamState[String] = { + if (currentPixel >= surface.width * surface.height) acc + else { + val x = currentPixel % surface.width + val y = (surface.height - 1) - (currentPixel / surface.width) // lines are stored upside down + val color = surface.unsafeGetPixel(x, y) + storePixels(storeColor, surface, currentPixel + 1, acc.flatMap(_ => storeColor(color))) + } + } + + def storeHeader(surface: Surface): ByteStreamState[String] = { + (for { + _ <- writeString("BM") + _ <- writeLENumber(14 + 40 + 3 * surface.width * surface.height, 4) // BMP size + _ <- writeBytes(List.fill(4)(0)) + _ <- writeLENumber(14 + 40, 4) // BMP offset + _ <- writeLENumber(40, 4) // DIB Header size + _ <- writeLENumber(surface.width, 4) + _ <- writeLENumber(surface.height, 4) + _ <- writeLENumber(1, 2) // Color planes + _ <- writeLENumber(24, 2) // Bits per pixel + _ <- writeLENumber(0, 4) // No compression + _ <- writeLENumber(0, 4) // Image size (can be 0) + _ <- writeLENumber(1, 4) // Horizontal Res (1 px/meter) + _ <- writeLENumber(1, 4) // Vertical Res (1 px/meter) + _ <- writeLENumber(0, 4) // Pallete colors (can be 0) + _ <- writeLENumber(0, 4) // Important colors (can be 0) + } yield ()) + } + + def storeImage(surface: Surface, os: OutputStream): Either[String, Unit] = { + val state = for { + _ <- storeHeader(surface) + _ <- storePixels(storeBgrPixel, surface) + } yield () + toOutputStream(state, os) + } +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/Header.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/Header.scala new file mode 100644 index 00000000..6f7b2b79 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/bmp/Header.scala @@ -0,0 +1,10 @@ +package eu.joaocosta.minart.graphics.image.bmp + +final case class Header( + magic: String, + size: Int, + offset: Int, + width: Int, + height: Int, + bitsPerPixel: Int +) diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageFormat.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageFormat.scala index 5cafa0ce..e8b1f388 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageFormat.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageFormat.scala @@ -2,11 +2,8 @@ package eu.joaocosta.minart.graphics.image.ppm import java.io.InputStream -import scala.annotation.tailrec - import eu.joaocosta.minart.graphics._ import eu.joaocosta.minart.graphics.image.helpers._ -import eu.joaocosta.minart.graphics.image.ppm /** Image loader and writer for PGM/PPM files. * diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageWriter.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageWriter.scala index 53345d1a..cfd42dc2 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageWriter.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageWriter.scala @@ -2,6 +2,8 @@ package eu.joaocosta.minart.graphics.image.ppm import java.io.OutputStream +import scala.annotation.tailrec + import eu.joaocosta.minart.graphics._ import eu.joaocosta.minart.graphics.image._ import eu.joaocosta.minart.graphics.image.helpers._ @@ -18,6 +20,7 @@ trait PpmImageWriter[F[_]] extends ImageWriter { private def storeBinaryRgbPixel(color: Color): ByteStreamState[String] = writeBytes(List(color.r, color.g, color.b)) + @tailrec private def storePixels( storeColor: Color => ByteStreamState[String], surface: Surface, @@ -26,7 +29,9 @@ trait PpmImageWriter[F[_]] extends ImageWriter { ): ByteStreamState[String] = { if (currentPixel >= surface.width * surface.height) acc else { - val color = surface.unsafeGetPixel(currentPixel % surface.width, currentPixel / surface.width) + val x = currentPixel % surface.width + val y = currentPixel / surface.width + val color = surface.unsafeGetPixel(x, y) storePixels(storeColor, surface, currentPixel + 1, acc.flatMap(_ => storeColor(color))) } } diff --git a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala index bc155eb3..b3451c99 100644 --- a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala +++ b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala @@ -19,5 +19,14 @@ object ImageWriterSpec extends BasicTestSuite { val newImage = ppm.PpmImageFormat.defaultFormat.loadImage(is).toOption.get assert(newImage.getPixels().map(_.toVector) == originalImage.getPixels().map(_.toVector)) } + + test("Write a BMP image") { + val originalImage = Image.loadBmpImage(Resource("scala.bmp")).get + val os = new ByteArrayOutputStream() + bmp.BmpImageFormat.defaultFormat.storeImage(originalImage, os) + val is = new ByteArrayInputStream(os.toByteArray) + val newImage = bmp.BmpImageFormat.defaultFormat.loadImage(is).toOption.get + assert(newImage.getPixels().map(_.toVector) == originalImage.getPixels().map(_.toVector)) + } } } From ad884323c4a589fb3910a8cf82c79f1b38c848c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Thu, 18 Aug 2022 16:33:00 +0100 Subject: [PATCH 3/7] Simplify image writer tests --- .../graphics/image/ImageWriterSpec.scala | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala index b3451c99..0784b0ba 100644 --- a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala +++ b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala @@ -9,24 +9,23 @@ import eu.joaocosta.minart.runtime._ object ImageWriterSpec extends BasicTestSuite { + def roundtripTest(baseResource: Resource, imageFormat: ImageLoader with ImageWriter) = { + val originalImage = baseResource.withInputStream(is => imageFormat.loadImage(is).toOption.get).get + val os = new ByteArrayOutputStream() + imageFormat.storeImage(originalImage, os) + val is = new ByteArrayInputStream(os.toByteArray) + val newImage = imageFormat.loadImage(is).toOption.get + assert(newImage.getPixels().map(_.toVector) == originalImage.getPixels().map(_.toVector)) + } + // Can't load resources in JS tests if (Platform() != Platform.JS) { test("Write a PPM image") { - val originalImage = Image.loadPpmImage(Resource("scala.ppm")).get - val os = new ByteArrayOutputStream() - ppm.PpmImageFormat.defaultFormat.storeImage(originalImage, os) - val is = new ByteArrayInputStream(os.toByteArray) - val newImage = ppm.PpmImageFormat.defaultFormat.loadImage(is).toOption.get - assert(newImage.getPixels().map(_.toVector) == originalImage.getPixels().map(_.toVector)) + roundtripTest(Resource("scala.ppm"), ppm.PpmImageFormat.defaultFormat) } test("Write a BMP image") { - val originalImage = Image.loadBmpImage(Resource("scala.bmp")).get - val os = new ByteArrayOutputStream() - bmp.BmpImageFormat.defaultFormat.storeImage(originalImage, os) - val is = new ByteArrayInputStream(os.toByteArray) - val newImage = bmp.BmpImageFormat.defaultFormat.loadImage(is).toOption.get - assert(newImage.getPixels().map(_.toVector) == originalImage.getPixels().map(_.toVector)) + roundtripTest(Resource("scala.bmp"), bmp.BmpImageFormat.defaultFormat) } } } From 79887bb1ab4d6d44e9f45340c95e32347125396a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Thu, 18 Aug 2022 16:46:09 +0100 Subject: [PATCH 4/7] Add QoiImageFormat --- .../minart/graphics/image/Image.scala | 2 +- .../minart/graphics/image/qoi/Header.scala | 9 +++ .../minart/graphics/image/qoi/Op.scala | 15 ++++ .../graphics/image/qoi/QoiImageFormat.scala | 16 ++++ .../image/{ => qoi}/QoiImageLoader.scala | 81 +++++++------------ 5 files changed, 69 insertions(+), 54 deletions(-) create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/Header.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/Op.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/QoiImageFormat.scala rename image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/{ => qoi}/QoiImageLoader.scala (72%) diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala index e92fca4d..45d7cdc5 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala @@ -37,5 +37,5 @@ object Image { /** Loads an image in the QOI format. */ def loadQoiImage(resource: Resource): Try[RamSurface] = - loadImage(QoiImageLoader.defaultLoader, resource) + loadImage(qoi.QoiImageFormat.defaultFormat, resource) } diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/Header.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/Header.scala new file mode 100644 index 00000000..766e885c --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/Header.scala @@ -0,0 +1,9 @@ +package eu.joaocosta.minart.graphics.image.qoi + +final case class Header( + magic: String, + width: Long, + height: Long, + channels: Byte, + colorspace: Byte +) diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/Op.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/Op.scala new file mode 100644 index 00000000..31452f0c --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/Op.scala @@ -0,0 +1,15 @@ +package eu.joaocosta.minart.graphics.image.qoi + +// Private structures +sealed trait Op +object Op { + final case class OpRgb(red: Int, green: Int, blue: Int) extends Op + final case class OpRgba(red: Int, green: Int, blue: Int, alpha: Int) extends Op + final case class OpIndex(index: Int) extends Op + final case class OpDiff(dr: Int, dg: Int, db: Int) extends Op + final case class OpLuma(dg: Int, drdg: Int, dbdg: Int) extends Op { + val dr = drdg + dg + val db = dbdg + dg + } + final case class OpRun(run: Int) extends Op +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/QoiImageFormat.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/QoiImageFormat.scala new file mode 100644 index 00000000..3da356eb --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/QoiImageFormat.scala @@ -0,0 +1,16 @@ +package eu.joaocosta.minart.graphics.image.qoi + +import java.io.InputStream + +import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.graphics.image.helpers._ + +/** Image loader for QOI files. + */ +final class QoiImageFormat[F[_]](val byteReader: ByteReader[F], val byteWriter: ByteWriter[F]) extends QoiImageLoader[F] + +object QoiImageFormat { + val defaultFormat = new QoiImageFormat[Iterator](ByteReader.IteratorByteReader, ByteWriter.IteratorByteWriter) + + val supportedFormats = Set("qoif") +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/QoiImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/QoiImageLoader.scala similarity index 72% rename from image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/QoiImageLoader.scala rename to image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/QoiImageLoader.scala index 86463827..585b2552 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/QoiImageLoader.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/QoiImageLoader.scala @@ -1,15 +1,19 @@ -package eu.joaocosta.minart.graphics.image +package eu.joaocosta.minart.graphics.image.qoi import java.io.InputStream import scala.collection.compat.immutable.LazyList import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.graphics.image._ import eu.joaocosta.minart.graphics.image.helpers._ /** Image loader for QOI files. */ -final class QoiImageLoader[F[_]](byteReader: ByteReader[F]) extends ImageLoader { +trait QoiImageLoader[F[_]] extends ImageLoader { + val byteReader: ByteReader[F] + + import QoiImageFormat._ import QoiImageLoader._ import byteReader._ @@ -116,65 +120,36 @@ final class QoiImageLoader[F[_]](byteReader: ByteReader[F]) extends ImageLoader } } + def loadHeader(bytes: F[Int]): ParseResult[Header] = { + ( + for { + magic <- readString(4).validate(supportedFormats, m => s"Unsupported format: $m") + width <- readBENumber(4) + height <- readBENumber(4) + channels <- readByte.collect({ case Some(byte) => byte.toByte }, _ => "Incomplete header: no channel byte") + colorspace <- readByte.collect( + { case Some(byte) => byte.toByte }, + _ => "Incomplete header: no color space byte" + ) + } yield Header( + magic, + width, + height, + channels, + colorspace + ) + ).run(bytes) + } + def loadImage(is: InputStream): Either[String, RamSurface] = { val bytes = fromInputStream(is) - Header.fromBytes(bytes)(byteReader).right.flatMap { case (data, header) => + loadHeader(bytes).right.flatMap { case (data, header) => asSurface(loadOps(data), header) } } } object QoiImageLoader { - val defaultLoader = new QoiImageLoader[Iterator](ByteReader.IteratorByteReader) - - val supportedFormats = Set("qoif") - - final case class Header( - magic: String, - width: Long, - height: Long, - channels: Byte, - colorspace: Byte - ) - - object Header { - def fromBytes[F[_]](bytes: F[Int])(byteReader: ByteReader[F]): byteReader.ParseResult[Header] = { - import byteReader._ - ( - for { - magic <- readString(4).validate(supportedFormats, m => s"Unsupported format: $m") - width <- readBENumber(4) - height <- readBENumber(4) - channels <- readByte.collect({ case Some(byte) => byte.toByte }, _ => "Incomplete header: no channel byte") - colorspace <- readByte.collect( - { case Some(byte) => byte.toByte }, - _ => "Incomplete header: no color space byte" - ) - } yield Header( - magic, - width, - height, - channels, - colorspace - ) - ).run(bytes) - } - } - - // Private structures - private sealed trait Op - private object Op { - final case class OpRgb(red: Int, green: Int, blue: Int) extends Op - final case class OpRgba(red: Int, green: Int, blue: Int, alpha: Int) extends Op - final case class OpIndex(index: Int) extends Op - final case class OpDiff(dr: Int, dg: Int, db: Int) extends Op - final case class OpLuma(dg: Int, drdg: Int, dbdg: Int) extends Op { - val dr = drdg + dg - val db = dbdg + dg - } - final case class OpRun(run: Int) extends Op - } - private final case class QoiColor(r: Int, g: Int, b: Int, a: Int) { def toMinartColor = Color(r, g, b) def hash = (r * 3 + g * 5 + b * 7 + a * 11) % 64 From 9db024084f88410b0017ccf5a1d9f09558debdd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Thu, 18 Aug 2022 20:14:08 +0100 Subject: [PATCH 5/7] Add a few image helper methods --- .../joaocosta/minart/runtime/Resource.scala | 4 +-- .../minart/graphics/image/Image.scala | 36 ++++++++++++++----- .../minart/graphics/image/ImageLoader.scala | 23 +++++++++++- .../minart/graphics/image/ImageWriter.scala | 25 ++++++++++++- .../graphics/image/ImageWriterSpec.scala | 15 ++++---- .../minart/runtime/pure/ResourceIOOps.scala | 2 +- 6 files changed, 86 insertions(+), 19 deletions(-) diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/runtime/Resource.scala b/core/shared/src/main/scala/eu/joaocosta/minart/runtime/Resource.scala index 1d7b7537..24de4376 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/runtime/Resource.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/runtime/Resource.scala @@ -68,8 +68,8 @@ trait Resource { /** Provides a [[java.io.OutputStream]] to write data to this resource location. * The OutputStream is closed in the end, so it should not escape this call. */ - def withOutputStream(f: OutputStream => Unit): Try[Unit] = - Using[OutputStream, Unit](unsafeOutputStream())(f) + def withOutputStream[A](f: OutputStream => A): Try[A] = + Using[OutputStream, A](unsafeOutputStream())(f) } object Resource { diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala index 45d7cdc5..499394f0 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala @@ -14,14 +14,10 @@ object Image { * @param resource Resource pointing to the image */ def loadImage(loader: ImageLoader, resource: Resource): Try[RamSurface] = { - resource - .withInputStream { inputStream => - loader.loadImage(inputStream) - } - .flatMap { - case Left(error) => Failure(new Exception(error)) - case Right(result) => Success(result) - } + loader.loadImage(resource).flatMap { + case Left(error) => Failure(new Exception(error)) + case Right(result) => Success(result) + } } /** Loads an image in the PPM format. @@ -38,4 +34,28 @@ object Image { */ def loadQoiImage(resource: Resource): Try[RamSurface] = loadImage(qoi.QoiImageFormat.defaultFormat, resource) + + /** Stores an image using a custom ImageWriter. + * + * @param writer ImageWriter to use + * @param surface Surface to store + * @param resource Resource pointing to the output destination + */ + def storeImage(writer: ImageWriter, surface: Surface, resource: Resource): Try[Unit] = { + writer.storeImage(surface, resource).flatMap { + case Left(error) => Failure(new Exception(error)) + case Right(result) => Success(result) + } + } + + /** Stores an image in the PPM format. + */ + def storePpmImage(surface: Surface, resource: Resource): Try[Unit] = + storeImage(ppm.PpmImageFormat.defaultFormat, surface, resource) + + /** Stores an image in the BMP format. + */ + def storeBmpImage(surface: Surface, resource: Resource): Try[Unit] = + storeImage(bmp.BmpImageFormat.defaultFormat, surface, resource) + } diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageLoader.scala index 746dc7f1..9a48cb52 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageLoader.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageLoader.scala @@ -1,8 +1,11 @@ package eu.joaocosta.minart.graphics.image -import java.io.InputStream +import java.io.{ByteArrayInputStream, InputStream} + +import scala.util.Try import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.runtime.Resource /** Image loader with a low-level implementation on how to load an image. */ @@ -14,4 +17,22 @@ trait ImageLoader { * @return Either a RamSurface with the image data or an error string */ def loadImage(is: InputStream): Either[String, RamSurface] + + /** Loads an image from a Resource. + * + * @param resource Resource with the image data + * @return Either a RamSurface with the image data or an error string, inside a Try capturing the IO exceptions + */ + def loadImage(resource: Resource): Try[Either[String, RamSurface]] = + resource.withInputStream(is => loadImage(is)) + + /** Loads an image from a byte array. + * + * @param data Byte array + * @return Either a RamSurface with the image data or an error string + */ + def fromByteArray(data: Array[Byte]): Either[String, RamSurface] = { + val is = new ByteArrayInputStream(data) + loadImage(is) + } } diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala index dc017fe2..1ae64f7d 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala @@ -1,8 +1,11 @@ package eu.joaocosta.minart.graphics.image -import java.io.OutputStream +import java.io.{ByteArrayOutputStream, OutputStream} + +import scala.util.Try import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.runtime.Resource /** Image writer with a low-level implementation on how to store an image. */ @@ -12,6 +15,26 @@ trait ImageWriter { * * @param surface Surface to store * @param os OutputStream where to store the data + * @return Either unit or an error string */ def storeImage(surface: Surface, os: OutputStream): Either[String, Unit] + + /** Stores a surface to a Resource. + * + * @param surface Surface to store + * @param resource Resource where to store the data + * @return Either unit or an error string, inside a Try capturing the IO exceptions + */ + def storeImage(surface: Surface, resource: Resource): Try[Either[String, Unit]] = + resource.withOutputStream(os => storeImage(surface, os)) + + /** Returns the image data as a byte array. + * + * @param surface Surface to convert + * @return Either a RamSurface with the image data or an error string + */ + def toByteArray(surface: Surface): Either[String, Array[Byte]] = { + val os = new ByteArrayOutputStream() + storeImage(surface, os).map(_ => os.toByteArray) + } } diff --git a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala index 0784b0ba..b3b6e490 100644 --- a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala +++ b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala @@ -10,12 +10,15 @@ import eu.joaocosta.minart.runtime._ object ImageWriterSpec extends BasicTestSuite { def roundtripTest(baseResource: Resource, imageFormat: ImageLoader with ImageWriter) = { - val originalImage = baseResource.withInputStream(is => imageFormat.loadImage(is).toOption.get).get - val os = new ByteArrayOutputStream() - imageFormat.storeImage(originalImage, os) - val is = new ByteArrayInputStream(os.toByteArray) - val newImage = imageFormat.loadImage(is).toOption.get - assert(newImage.getPixels().map(_.toVector) == originalImage.getPixels().map(_.toVector)) + val (oldPixels, newPixels) = (for { + original <- imageFormat.loadImage(baseResource).get + originalPixels = original.getPixels().map(_.toVector) + stored <- imageFormat.toByteArray(original) + loaded <- imageFormat.fromByteArray(stored) + loadedPixels = loaded.getPixels().map(_.toVector) + } yield (originalPixels, loadedPixels)).toOption.get + + assert(oldPixels == newPixels) } // Can't load resources in JS tests diff --git a/pure/shared/src/main/scala/eu/joaocosta/minart/runtime/pure/ResourceIOOps.scala b/pure/shared/src/main/scala/eu/joaocosta/minart/runtime/pure/ResourceIOOps.scala index 3dca4e8b..187acfd9 100644 --- a/pure/shared/src/main/scala/eu/joaocosta/minart/runtime/pure/ResourceIOOps.scala +++ b/pure/shared/src/main/scala/eu/joaocosta/minart/runtime/pure/ResourceIOOps.scala @@ -50,6 +50,6 @@ trait ResourceIOOps { /** Provides a [[java.io.OutputStream]] to write data to this resource location. * The OutputStream is closed in the end, so it should not escape this call. */ - def withOutputStream(f: OutputStream => Unit): ResourceIO[Try[Unit]] = + def withOutputStream[A](f: OutputStream => A): ResourceIO[Try[A]] = accessResource(_.withOutputStream(f)) } From 0f48c7ade540903f11bba8966f3e09ea4e98abad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Thu, 18 Aug 2022 21:08:07 +0100 Subject: [PATCH 6/7] Add deprecated image methods --- .../minart/graphics/image/BmpImageLoader.scala | 11 +++++++++++ .../minart/graphics/image/PpmImageLoader.scala | 11 +++++++++++ .../minart/graphics/image/QoiImageLoader.scala | 11 +++++++++++ 3 files changed, 33 insertions(+) create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/BmpImageLoader.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/PpmImageLoader.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/QoiImageLoader.scala diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/BmpImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/BmpImageLoader.scala new file mode 100644 index 00000000..9dbccbaf --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/BmpImageLoader.scala @@ -0,0 +1,11 @@ +package eu.joaocosta.minart.graphics.image + +import eu.joaocosta.minart.graphics.image.helpers._ + +@deprecated("Use eu.joaocosta.minart.graphics.image.bmp.BmpImageFormat instead") +final class BmpImageLoader[F[_]](val byteReader: ByteReader[F]) extends bmp.BmpImageLoader[F] + +object BmpImageLoader { + @deprecated("Use eu.joaocosta.minart.graphics.image.bmp.BmpImageFormat.defaultFormat instead") + val defaultLoader = bmp.BmpImageFormat.defaultFormat +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/PpmImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/PpmImageLoader.scala new file mode 100644 index 00000000..e0ca1d5b --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/PpmImageLoader.scala @@ -0,0 +1,11 @@ +package eu.joaocosta.minart.graphics.image + +import eu.joaocosta.minart.graphics.image.helpers._ + +@deprecated("Use eu.joaocosta.minart.graphics.image.ppm.PpmImageFormat instead") +final class PpmImageLoader[F[_]](val byteReader: ByteReader[F]) extends ppm.PpmImageLoader[F] + +object PpmImageLoader { + @deprecated("Use eu.joaocosta.minart.graphics.image.ppm.PpmImageFormat.defaultFormat instead") + val defaultLoader = ppm.PpmImageFormat.defaultFormat +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/QoiImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/QoiImageLoader.scala new file mode 100644 index 00000000..c1678cf4 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/QoiImageLoader.scala @@ -0,0 +1,11 @@ +package eu.joaocosta.minart.graphics.image + +import eu.joaocosta.minart.graphics.image.helpers._ + +@deprecated("Use eu.joaocosta.minart.graphics.image.qoi.QoiImageFormat instead") +final class QoiImageLoader[F[_]](val byteReader: ByteReader[F]) extends qoi.QoiImageLoader[F] + +object QoiImageLoader { + @deprecated("Use eu.joaocosta.minart.graphics.image.qoi.QoiImageFormat.defaultFormat instead") + val defaultLoader = qoi.QoiImageFormat.defaultFormat +} From 2112ce73cd6adda0bc56f5abc164b22fadac7bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Thu, 18 Aug 2022 21:22:09 +0100 Subject: [PATCH 7/7] Fix compilation on 2.11 and 2.12 --- .../eu/joaocosta/minart/graphics/image/ImageWriter.scala | 2 +- .../minart/graphics/image/helpers/ByteReader.scala | 2 +- .../minart/graphics/image/helpers/ByteWriter.scala | 4 ++-- .../joaocosta/minart/graphics/image/ImageWriterSpec.scala | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala index 1ae64f7d..1c3b47d3 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala @@ -35,6 +35,6 @@ trait ImageWriter { */ def toByteArray(surface: Surface): Either[String, Array[Byte]] = { val os = new ByteArrayOutputStream() - storeImage(surface, os).map(_ => os.toByteArray) + storeImage(surface, os).right.map(_ => os.toByteArray) } } diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteReader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteReader.scala index 99220d15..bac008c8 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteReader.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteReader.scala @@ -118,7 +118,7 @@ object ByteReader { val buffer = List.newBuilder[Int] while (bufferedBytes.hasNext && p(bufferedBytes.head)) { buffer += bufferedBytes.head - bufferedBytes.next + bufferedBytes.next() } bufferedBytes -> buffer.result() } diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteWriter.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteWriter.scala index d6ed20b3..915b4da9 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteWriter.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/helpers/ByteWriter.scala @@ -45,7 +45,7 @@ trait ByteWriter[F[_]] { object ByteWriter { object LazyListByteWriter extends ByteWriter[LazyList] { def toOutputStream[E](data: ByteStreamState[E], os: OutputStream): Either[E, Unit] = - data.run(LazyList.empty[Array[Byte]]).map { case (s, _) => + data.run(LazyList.empty[Array[Byte]]).right.map { case (s, _) => s.foreach(bytes => os.write(bytes)) } @@ -59,7 +59,7 @@ object ByteWriter { object IteratorByteWriter extends ByteWriter[Iterator] { def toOutputStream[E](data: ByteStreamState[E], os: OutputStream): Either[E, Unit] = - data.run(Iterator.empty[Array[Byte]]).map { case (s, _) => + data.run(Iterator.empty).right.map { case (s, _) => s.foreach(bytes => os.write(bytes)) } diff --git a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala index b3b6e490..2e15a31b 100644 --- a/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala +++ b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala @@ -11,12 +11,12 @@ object ImageWriterSpec extends BasicTestSuite { def roundtripTest(baseResource: Resource, imageFormat: ImageLoader with ImageWriter) = { val (oldPixels, newPixels) = (for { - original <- imageFormat.loadImage(baseResource).get + original <- imageFormat.loadImage(baseResource).get.right.toOption originalPixels = original.getPixels().map(_.toVector) - stored <- imageFormat.toByteArray(original) - loaded <- imageFormat.fromByteArray(stored) + stored <- imageFormat.toByteArray(original).right.toOption + loaded <- imageFormat.fromByteArray(stored).right.toOption loadedPixels = loaded.getPixels().map(_.toVector) - } yield (originalPixels, loadedPixels)).toOption.get + } yield (originalPixels, loadedPixels)).get assert(oldPixels == newPixels) }