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/BmpImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/BmpImageLoader.scala index 07e5379d..9dbccbaf 100644 --- 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 @@ -1,125 +1,11 @@ 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)) - } - } - } -} +@deprecated("Use eu.joaocosta.minart.graphics.image.bmp.BmpImageFormat instead") +final class BmpImageLoader[F[_]](val byteReader: ByteReader[F]) extends bmp.BmpImageLoader[F] 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) - } - } + @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/Image.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/Image.scala index 489ce9bf..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,28 +14,48 @@ 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. */ def loadPpmImage(resource: Resource): Try[RamSurface] = - loadImage(PpmImageLoader.defaultLoader, resource) + loadImage(ppm.PpmImageFormat.defaultFormat, resource) /** 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. */ def loadQoiImage(resource: Resource): Try[RamSurface] = - loadImage(QoiImageLoader.defaultLoader, resource) + 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 new file mode 100644 index 00000000..1c3b47d3 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ImageWriter.scala @@ -0,0 +1,40 @@ +package eu.joaocosta.minart.graphics.image + +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. + */ +trait ImageWriter { + + /** Stores a surface to an OutputStream. + * + * @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).right.map(_ => os.toByteArray) + } +} 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 index 92c5390c..e0ca1d5b 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/PpmImageLoader.scala @@ -1,168 +1,11 @@ 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 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 { - import PpmImageLoader._ - private val byteStringOps = new ByteStringOps(byteReader) - import byteReader._ - import byteStringOps._ - - // P2 - private val loadStringGrayscalePixel: ParseState[String, Color] = - ( - for { - value <- parseNextInt("Invalid value") - } yield Color(value, value, value) - ) - - // P3 - private val loadStringRgbPixel: ParseState[String, Color] = - ( - for { - red <- parseNextInt("Invalid red channel") - green <- parseNextInt("Invalid green channel") - blue <- parseNextInt("Invalid blue channel") - } yield Color(red, green, blue) - ) - - // P5 - private val loadBinaryGrayscalePixel: ParseState[String, Color] = - readByte.collect( - { case Some(byte) => Color(byte, byte, byte) }, - _ => "Not enough data to read Grayscale pixel" - ) - - // P6 - private val loadBinaryRgbPixel: ParseState[String, Color] = - readBytes(3).collect( - { case bytes if bytes.size == 3 => Color(bytes(0), bytes(1), bytes(2)) }, - _ => "Not enough data to read RGB 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.magic match { - case "P2" => - loadPixels(loadStringGrayscalePixel, data, numPixels) - case "P3" => - loadPixels(loadStringRgbPixel, data, numPixels) - case "P5" => - loadPixels(loadBinaryGrayscalePixel, data, numPixels) - case "P6" => - loadPixels(loadBinaryRgbPixel, data, numPixels) - case fmt => - Left(s"Invalid pixel format: $fmt") - } - 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)) - } - } - } -} +@deprecated("Use eu.joaocosta.minart.graphics.image.ppm.PpmImageFormat instead") +final class PpmImageLoader[F[_]](val byteReader: ByteReader[F]) extends ppm.PpmImageLoader[F] 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 - private val comment = '#'.toInt - private val space = ' '.toInt - - val readNextLine: ParseState[Nothing, List[Int]] = State[F[Int], List[Int]] { bytes => - @tailrec - def aux(b: F[Int]): (F[Int], List[Int]) = { - val (remaining, line) = (for { - chars <- readWhile(_ != newLine) - fullChars = chars :+ newLine - _ <- skipBytes(1) - } yield fullChars).run(b).merge - if (line.headOption.exists(c => c == comment || c == newLine)) - aux(remaining) - else - remaining -> line - } - aux(bytes) - } - - val readNextString: ParseState[Nothing, String] = - readNextLine.flatMap { line => - val chars = line.takeWhile(c => c != space).map(_.toChar) - val remainingLine = line.drop(chars.size + 1) - val string = chars.mkString("").trim - if (remainingLine.isEmpty) - State.pure(string) - else - pushBytes(remainingLine :+ newLine).map(_ => string) - } - - def parseNextInt(errorMessage: String): ParseState[String, Int] = - readNextString.flatMap { str => - val intEither = - try { - Right(str.toInt) - } catch { - case _: Throwable => - Left(s"$errorMessage: $str") - } - State.fromEither(intEither) - } - - } + @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 index 86463827..c1678cf4 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/QoiImageLoader.scala @@ -1,193 +1,11 @@ package eu.joaocosta.minart.graphics.image -import java.io.InputStream - -import scala.collection.compat.immutable.LazyList - -import eu.joaocosta.minart.graphics._ import eu.joaocosta.minart.graphics.image.helpers._ -/** Image loader for QOI files. - */ -final class QoiImageLoader[F[_]](byteReader: ByteReader[F]) extends ImageLoader { - import QoiImageLoader._ - import byteReader._ - - // Binary helpers - private def wrapAround(b: Int): Int = if (b >= 0) b % 256 else 256 + b - private def load2Bits(b: Int, bias: Int = 2): Int = (b & 0x03) - bias - private def load4Bits(b: Int, bias: Int = 8): Int = (b & 0x0f) - bias - private def load6Bits(b: Int, bias: Int = 32): Int = (b & 0x3f) - bias - - // Op loading - private val opFromBytes: ParseState[String, Op] = { - import Op._ - readByte - .collect( - { case Some(tag) => - (tag & 0xc0, tag & 0x3f) - }, - _ => "Corrupted file, expected a Op but got nothing" - ) - .flatMap { - case (0xc0, 0x3e) => - readBytes(3) - .validate(_.size == 3, _ => "Not enough data for OP_RGB") - .map(data => OpRgb(data(0), data(1), data(2))) - case (0xc0, 0x3f) => - readBytes(4) - .validate(_.size == 4, _ => "Not enough data for OP_RGBA") - .map(data => OpRgba(data(0), data(1), data(2), data(3))) - case (0x00, index) => - State.pure(OpIndex(index)) - case (0x40, diffs) => - State.pure(OpDiff(load2Bits(diffs >> 4), load2Bits(diffs >> 2), load2Bits(diffs))) - case (0x80, dg) => - readByte.collect( - { case Some(byte) => OpLuma(load6Bits(dg), load4Bits(byte >> 4), load4Bits(byte)) }, - _ => "Not enough data for OP_LUMA" - ) - case (0xc0, run) => - State.pure(OpRun(run + 1)) - } - } - - private def loadOps(bytes: F[Int]): LazyList[Either[String, Op]] = - if (isEmpty(bytes)) LazyList.empty - else - opFromBytes.run(bytes) match { - case Left(error) => LazyList(Left(error)) - case Right((remaining, op)) => Right(op) #:: loadOps(remaining) - } - - // State iteration - private def nextState(state: QoiState, chunk: Op): QoiState = { - import Op._ - chunk match { - case OpRgb(red, green, blue) => - val color = QoiColor(red, green, blue, state.previousColor.a) - state.addColor(color) - case OpRgba(red, green, blue, alpha) => - val color = QoiColor(red, green, blue, alpha) - state.addColor(color) - case OpIndex(index) => - QoiState(state.colorMap(index) :: state.imageAcc, state.colorMap) - case OpDiff(dr, dg, db) => - val color = QoiColor( - wrapAround(state.previousColor.r + dr), - wrapAround(state.previousColor.g + dg), - wrapAround(state.previousColor.b + db), - state.previousColor.a - ) - state.addColor(color) - case luma: OpLuma => - val color = QoiColor( - wrapAround(state.previousColor.r + luma.dr), - wrapAround(state.previousColor.g + luma.dg), - wrapAround(state.previousColor.b + luma.db), - state.previousColor.a - ) - state.addColor(color) - case OpRun(run) => - QoiState(List.fill(run)(state.previousColor) ++ state.imageAcc, state.colorMap) - } - } - - // Image reconstruction - private def asSurface(ops: LazyList[Either[String, Op]], header: Header): Either[String, RamSurface] = { - ops - .foldLeft[Either[String, QoiState]](Right(QoiState())) { case (eitherState, eitherOp) => - for { - state <- eitherState.right - op <- eitherOp.right - } yield nextState(state, op) - } - .right - .flatMap { finalState => - val flatPixels = finalState.imageAcc.reverse - val expectedPixels = (header.width * header.height).toInt - Either.cond( - flatPixels.size >= expectedPixels, - new RamSurface( - flatPixels.take(expectedPixels).map(_.toMinartColor).grouped(header.width.toInt).map(_.toArray).toVector - ), - s"Invalid number of pixels! Got ${flatPixels.size}, expected ${expectedPixels}" - ) - } - } - - def loadImage(is: InputStream): Either[String, RamSurface] = { - val bytes = fromInputStream(is) - Header.fromBytes(bytes)(byteReader).right.flatMap { case (data, header) => - asSurface(loadOps(data), header) - } - } -} +@deprecated("Use eu.joaocosta.minart.graphics.image.qoi.QoiImageFormat instead") +final class QoiImageLoader[F[_]](val byteReader: ByteReader[F]) extends qoi.QoiImageLoader[F] 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 - } - - private final case class QoiState( - imageAcc: List[QoiColor] = Nil, - colorMap: Vector[QoiColor] = Vector.fill(64)(QoiColor(0, 0, 0, 0)) - ) { - lazy val previousColor = imageAcc.headOption.getOrElse(QoiColor(0, 0, 0, 255)) - - def addColor(color: QoiColor): QoiState = { - QoiState(color :: imageAcc, colorMap.updated(color.hash, color)) - } - } + @deprecated("Use eu.joaocosta.minart.graphics.image.qoi.QoiImageFormat.defaultFormat instead") + val defaultLoader = qoi.QoiImageFormat.defaultFormat } 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/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 new file mode 100644 index 00000000..915b4da9 --- /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]]).right.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).right.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..e8b1f388 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageFormat.scala @@ -0,0 +1,20 @@ +package eu.joaocosta.minart.graphics.image.ppm + +import java.io.InputStream + +import eu.joaocosta.minart.graphics._ +import eu.joaocosta.minart.graphics.image.helpers._ + +/** 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/ppm/PpmImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageLoader.scala new file mode 100644 index 00000000..f40796e5 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageLoader.scala @@ -0,0 +1,158 @@ +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. + */ +trait PpmImageLoader[F[_]] extends ImageLoader { + val byteReader: ByteReader[F] + + import PpmImageFormat._ + import PpmImageLoader._ + private val byteStringOps = new ByteStringOps(byteReader) + import byteReader._ + import byteStringOps._ + + // P2 + private val loadStringGrayscalePixel: ParseState[String, Color] = + ( + for { + value <- parseNextInt("Invalid value") + } yield Color(value, value, value) + ) + + // P3 + private val loadStringRgbPixel: ParseState[String, Color] = + ( + for { + red <- parseNextInt("Invalid red channel") + green <- parseNextInt("Invalid green channel") + blue <- parseNextInt("Invalid blue channel") + } yield Color(red, green, blue) + ) + + // P5 + private val loadBinaryGrayscalePixel: ParseState[String, Color] = + readByte.collect( + { case Some(byte) => Color(byte, byte, byte) }, + _ => "Not enough data to read Grayscale pixel" + ) + + // P6 + private val loadBinaryRgbPixel: ParseState[String, Color] = + readBytes(3).collect( + { case bytes if bytes.size == 3 => Color(bytes(0), bytes(1), bytes(2)) }, + _ => "Not enough data to read RGB 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] = { + 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) + loadHeader(bytes).right.flatMap { case (data, header) => + val numPixels = header.width * header.height + val pixels = header.magic match { + case "P2" => + loadPixels(loadStringGrayscalePixel, data, numPixels) + case "P3" => + loadPixels(loadStringRgbPixel, data, numPixels) + case "P5" => + loadPixels(loadBinaryGrayscalePixel, data, numPixels) + case "P6" => + loadPixels(loadBinaryRgbPixel, data, numPixels) + case fmt => + Left(s"Invalid pixel format: $fmt") + } + 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)) + } + } + } +} + +object PpmImageLoader { + private final class ByteStringOps[F[_]](val byteReader: ByteReader[F]) { + import byteReader._ + private val newLine = '\n'.toInt + private val comment = '#'.toInt + private val space = ' '.toInt + + val readNextLine: ParseState[Nothing, List[Int]] = State[F[Int], List[Int]] { bytes => + @tailrec + def aux(b: F[Int]): (F[Int], List[Int]) = { + val (remaining, line) = (for { + chars <- readWhile(_ != newLine) + fullChars = chars :+ newLine + _ <- skipBytes(1) + } yield fullChars).run(b).merge + if (line.headOption.exists(c => c == comment || c == newLine)) + aux(remaining) + else + remaining -> line + } + aux(bytes) + } + + val readNextString: ParseState[Nothing, String] = + readNextLine.flatMap { line => + val chars = line.takeWhile(c => c != space).map(_.toChar) + val remainingLine = line.drop(chars.size + 1) + val string = chars.mkString("").trim + if (remainingLine.isEmpty) + State.pure(string) + else + pushBytes(remainingLine :+ newLine).map(_ => string) + } + + def parseNextInt(errorMessage: String): ParseState[String, Int] = + readNextString.flatMap { str => + val intEither = + try { + Right(str.toInt) + } catch { + case _: Throwable => + Left(s"$errorMessage: $str") + } + 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..cfd42dc2 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/ppm/PpmImageWriter.scala @@ -0,0 +1,53 @@ +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._ + +/** 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)) + + @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 = currentPixel / surface.width + val color = surface.unsafeGetPixel(x, y) + 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/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/qoi/QoiImageLoader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/QoiImageLoader.scala new file mode 100644 index 00000000..585b2552 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/qoi/QoiImageLoader.scala @@ -0,0 +1,168 @@ +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. + */ +trait QoiImageLoader[F[_]] extends ImageLoader { + val byteReader: ByteReader[F] + + import QoiImageFormat._ + import QoiImageLoader._ + import byteReader._ + + // Binary helpers + private def wrapAround(b: Int): Int = if (b >= 0) b % 256 else 256 + b + private def load2Bits(b: Int, bias: Int = 2): Int = (b & 0x03) - bias + private def load4Bits(b: Int, bias: Int = 8): Int = (b & 0x0f) - bias + private def load6Bits(b: Int, bias: Int = 32): Int = (b & 0x3f) - bias + + // Op loading + private val opFromBytes: ParseState[String, Op] = { + import Op._ + readByte + .collect( + { case Some(tag) => + (tag & 0xc0, tag & 0x3f) + }, + _ => "Corrupted file, expected a Op but got nothing" + ) + .flatMap { + case (0xc0, 0x3e) => + readBytes(3) + .validate(_.size == 3, _ => "Not enough data for OP_RGB") + .map(data => OpRgb(data(0), data(1), data(2))) + case (0xc0, 0x3f) => + readBytes(4) + .validate(_.size == 4, _ => "Not enough data for OP_RGBA") + .map(data => OpRgba(data(0), data(1), data(2), data(3))) + case (0x00, index) => + State.pure(OpIndex(index)) + case (0x40, diffs) => + State.pure(OpDiff(load2Bits(diffs >> 4), load2Bits(diffs >> 2), load2Bits(diffs))) + case (0x80, dg) => + readByte.collect( + { case Some(byte) => OpLuma(load6Bits(dg), load4Bits(byte >> 4), load4Bits(byte)) }, + _ => "Not enough data for OP_LUMA" + ) + case (0xc0, run) => + State.pure(OpRun(run + 1)) + } + } + + private def loadOps(bytes: F[Int]): LazyList[Either[String, Op]] = + if (isEmpty(bytes)) LazyList.empty + else + opFromBytes.run(bytes) match { + case Left(error) => LazyList(Left(error)) + case Right((remaining, op)) => Right(op) #:: loadOps(remaining) + } + + // State iteration + private def nextState(state: QoiState, chunk: Op): QoiState = { + import Op._ + chunk match { + case OpRgb(red, green, blue) => + val color = QoiColor(red, green, blue, state.previousColor.a) + state.addColor(color) + case OpRgba(red, green, blue, alpha) => + val color = QoiColor(red, green, blue, alpha) + state.addColor(color) + case OpIndex(index) => + QoiState(state.colorMap(index) :: state.imageAcc, state.colorMap) + case OpDiff(dr, dg, db) => + val color = QoiColor( + wrapAround(state.previousColor.r + dr), + wrapAround(state.previousColor.g + dg), + wrapAround(state.previousColor.b + db), + state.previousColor.a + ) + state.addColor(color) + case luma: OpLuma => + val color = QoiColor( + wrapAround(state.previousColor.r + luma.dr), + wrapAround(state.previousColor.g + luma.dg), + wrapAround(state.previousColor.b + luma.db), + state.previousColor.a + ) + state.addColor(color) + case OpRun(run) => + QoiState(List.fill(run)(state.previousColor) ++ state.imageAcc, state.colorMap) + } + } + + // Image reconstruction + private def asSurface(ops: LazyList[Either[String, Op]], header: Header): Either[String, RamSurface] = { + ops + .foldLeft[Either[String, QoiState]](Right(QoiState())) { case (eitherState, eitherOp) => + for { + state <- eitherState.right + op <- eitherOp.right + } yield nextState(state, op) + } + .right + .flatMap { finalState => + val flatPixels = finalState.imageAcc.reverse + val expectedPixels = (header.width * header.height).toInt + Either.cond( + flatPixels.size >= expectedPixels, + new RamSurface( + flatPixels.take(expectedPixels).map(_.toMinartColor).grouped(header.width.toInt).map(_.toArray).toVector + ), + s"Invalid number of pixels! Got ${flatPixels.size}, expected ${expectedPixels}" + ) + } + } + + 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) + loadHeader(bytes).right.flatMap { case (data, header) => + asSurface(loadOps(data), header) + } + } +} + +object QoiImageLoader { + 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 + } + + private final case class QoiState( + imageAcc: List[QoiColor] = Nil, + colorMap: Vector[QoiColor] = Vector.fill(64)(QoiColor(0, 0, 0, 0)) + ) { + lazy val previousColor = imageAcc.headOption.getOrElse(QoiColor(0, 0, 0, 255)) + + def addColor(color: QoiColor): QoiState = { + QoiState(color :: imageAcc, colorMap.updated(color.hash, color)) + } + } +} 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..2e15a31b --- /dev/null +++ b/image/shared/src/test/scala/eu/joaocosta/minart/graphics/image/ImageWriterSpec.scala @@ -0,0 +1,34 @@ +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 { + + def roundtripTest(baseResource: Resource, imageFormat: ImageLoader with ImageWriter) = { + val (oldPixels, newPixels) = (for { + original <- imageFormat.loadImage(baseResource).get.right.toOption + originalPixels = original.getPixels().map(_.toVector) + stored <- imageFormat.toByteArray(original).right.toOption + loaded <- imageFormat.fromByteArray(stored).right.toOption + loadedPixels = loaded.getPixels().map(_.toVector) + } yield (originalPixels, loadedPixels)).get + + assert(oldPixels == newPixels) + } + + // Can't load resources in JS tests + if (Platform() != Platform.JS) { + test("Write a PPM image") { + roundtripTest(Resource("scala.ppm"), ppm.PpmImageFormat.defaultFormat) + } + + test("Write a BMP image") { + roundtripTest(Resource("scala.bmp"), bmp.BmpImageFormat.defaultFormat) + } + } +} 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)) }