From 2bd612052a427473bcde722364b49b7ab14de814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Costa?= Date: Sat, 15 Jun 2024 15:48:48 +0100 Subject: [PATCH 1/2] Add Playdate Image (PDI) support --- README.md | 2 +- docs/_docs/overview.md | 2 +- .../minart/graphics/image/Image.scala | 10 ++ .../graphics/image/pdi/CellHeader.scala | 12 +++ .../minart/graphics/image/pdi/Header.scala | 6 ++ .../graphics/image/pdi/PdiImageFormat.scala | 13 +++ .../graphics/image/pdi/PdiImageReader.scala | 87 ++++++++++++++++++ .../graphics/image/pdi/PdiImageWriter.scala | 70 ++++++++++++++ .../src/test/resources/alpha/pdi-2bit.pdi | Bin 0 -> 25504 bytes .../graphics/image/ImageReaderSpec.scala | 12 +++ .../graphics/image/ImageWriterSpec.scala | 4 + 11 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/CellHeader.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/Header.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageFormat.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageReader.scala create mode 100644 image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageWriter.scala create mode 100644 image/shared/src/test/resources/alpha/pdi-2bit.pdi diff --git a/README.md b/README.md index 614374ee..4f899d2f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ To know more about the library and how to get started check the [microsite](http * Keyboard and pointer input * Surface blitting (with multiple blending modes) * Surface views and infinite planes -* Reading and Writing PPM, BMP and QOI images +* Reading and Writing PPM, BMP, QOI and PDI images * Audio playback (mono) * Procedural audio generation * Reading and Writing RTTL, WAV, AIFF and QOA sound files diff --git a/docs/_docs/overview.md b/docs/_docs/overview.md index ddfecb7a..f3b4fb91 100644 --- a/docs/_docs/overview.md +++ b/docs/_docs/overview.md @@ -45,7 +45,7 @@ Not only is mouse input supported, but touch screen input also comes for free. A `Resource` abstraction provides a backend-agnostic way to load and store resources. -Codecs for some image formats (PPM, BMP and QOI) is also included. +Codecs for some image formats (PPM, BMP, QOI and PDI) are also included. The same is true for some audio formats (RTTL, WAV, AIFF and QOA). ## Minart runtime and other runtimes 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 e4496eb3..47d3f00e 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 @@ -35,6 +35,11 @@ object Image { def loadQoiImage(resource: Resource): Try[RamSurface] = loadImage(qoi.QoiImageFormat.defaultFormat, resource) + /** Loads an image in the PDI format. + */ + def loadPdiImage(resource: Resource): Try[RamSurface] = + loadImage(pdi.PdiImageFormat.defaultFormat, resource) + /** Stores an image using a custom ImageWriter. * * @param writer ImageWriter to use @@ -63,4 +68,9 @@ object Image { def storeQoiImage(surface: Surface, resource: Resource): Try[Unit] = storeImage(qoi.QoiImageFormat.defaultFormat, surface, resource) + /** Stores an image in the PDI format. + */ + def storePdiImage(surface: Surface, resource: Resource): Try[Unit] = + storeImage(pdi.PdiImageFormat.defaultFormat, surface, resource) + } diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/CellHeader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/CellHeader.scala new file mode 100644 index 00000000..188e34ad --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/CellHeader.scala @@ -0,0 +1,12 @@ +package eu.joaocosta.minart.graphics.image.pdi + +private[pdi] final case class CellHeader( + clipWidth: Int, + clipHeight: Int, + stride: Int, + clipLeft: Int, + clipRight: Int, + clipTop: Int, + clipBottom: Int, + transparency: Boolean +) diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/Header.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/Header.scala new file mode 100644 index 00000000..58dfbb37 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/Header.scala @@ -0,0 +1,6 @@ +package eu.joaocosta.minart.graphics.image.pdi + +private[pdi] final case class Header( + magic: String, + compressed: Boolean +) diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageFormat.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageFormat.scala new file mode 100644 index 00000000..748fdced --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageFormat.scala @@ -0,0 +1,13 @@ +package eu.joaocosta.minart.graphics.image.pdi + +/** Image reader and writer for PDI files. + * + * Supports reading and writing uncompressed Playdate PDIs. + */ +final class PdiImageFormat() extends PdiImageReader with PdiImageWriter + +object PdiImageFormat { + val defaultFormat = new PdiImageFormat() + + val supportedFormats = Set("Playdate IMG") +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageReader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageReader.scala new file mode 100644 index 00000000..5b129df2 --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageReader.scala @@ -0,0 +1,87 @@ +package eu.joaocosta.minart.graphics.image.pdi + +import java.io.InputStream + +import eu.joaocosta.minart.graphics.* +import eu.joaocosta.minart.graphics.image.* +import eu.joaocosta.minart.internal.* + +/** Image reader for PDI files. + * + * Supports uncompressed Playdate PDIs. + */ +trait PdiImageReader extends ImageReader { + import ByteReader.* + + private def bitsFromByte(byte: Byte, entries: Int): List[Boolean] = + if (entries > 0) { + val colorBit = (byte & 0x80) >> 7 + (colorBit == 1) :: bitsFromByte(((byte << 1) & 0xff).toByte, entries - 1) + } else Nil + + private def loadBitLine( + data: CustomInputStream, + width: Int, + lineBytes: Int + ): Array[Boolean] = { + val bytes = data.readNBytes(lineBytes) + bytes.flatMap(b => bitsFromByte(b, 8)).take(width) + } + + private def loadHeader(bytes: CustomInputStream): ParseResult[Header] = { + (for { + magic <- readString(12).validate( + PdiImageFormat.supportedFormats, + m => s"Unsupported format: $m." + ) + flags <- readLENumber(4) + compression <- State.cond((flags & 0x80000000) == 0, false, "Unsuported compression") + header = Header(magic, compression) + } yield header).run(bytes) + } + + private def loadCellHeader(bytes: CustomInputStream): ParseResult[CellHeader] = { + (for { + clipWidth <- readLENumber(2) + clipHeight <- readLENumber(2) + cellStride <- readLENumber(2) + clipLeft <- readLENumber(2) + clipRight <- readLENumber(2) + clipTop <- readLENumber(2) + clipBottom <- readLENumber(2) + flags <- readLENumber(2) + transparency = (flags & 0x0003) != 0 + header = CellHeader(clipWidth, clipHeight, cellStride, clipLeft, clipRight, clipTop, clipBottom, transparency) + } yield header).run(bytes) + } + + final def loadImage(is: InputStream): Either[String, RamSurface] = { + val bytes = fromInputStream(is) + loadHeader(bytes).flatMap(_ => loadCellHeader(bytes)).map { case (data, cellHeader) => + val emptyColor = Color(0, 0, 0, 0) + val width = cellHeader.clipLeft + cellHeader.clipWidth + cellHeader.clipRight + val leftPad = Array.fill(cellHeader.clipLeft)(emptyColor) + val rightPad = Array.fill(cellHeader.clipRight)(emptyColor) + val centerColors = + (0 until cellHeader.clipHeight).map { _ => + loadBitLine(data, cellHeader.clipWidth, cellHeader.stride) + }.toVector + val centerMask = + (0 until cellHeader.clipHeight).map { _ => + loadBitLine(data, cellHeader.clipWidth, cellHeader.stride) + }.toVector + val centerPixels = centerColors.zip(centerMask).map { (colorLine, maskLine) => + colorLine.zip(maskLine).map { + case (_, false) => Color(0, 0, 0, 0) + case (false, true) => Color(0, 0, 0) + case (true, true) => Color(255, 255, 255) + } + } + val pixels = + Vector.fill(cellHeader.clipTop)(Array.fill(width)(emptyColor)) ++ + centerPixels.map(center => leftPad ++ center ++ rightPad) ++ + Vector.fill(cellHeader.clipBottom)(Array.fill(width)(emptyColor)) + new RamSurface(pixels) + } + } +} diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageWriter.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageWriter.scala new file mode 100644 index 00000000..6bd0bdde --- /dev/null +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageWriter.scala @@ -0,0 +1,70 @@ +package eu.joaocosta.minart.graphics.image.pdi + +import java.io.OutputStream + +import scala.annotation.tailrec + +import eu.joaocosta.minart.graphics.* +import eu.joaocosta.minart.graphics.image.* +import eu.joaocosta.minart.internal.* + +/** Image writer for Pdi files. + * + * Stores data as uncompressed PlayDate PDIs. + */ +trait PdiImageWriter extends ImageWriter { + import ByteWriter.* + + private def colorsToByte(acc: Byte, colors: List[Color], f: Color => Boolean): Byte = colors match { + case Nil => acc + case color :: rem => + if (f(color)) colorsToByte(((acc << 1) | 0x01).toByte, rem, f) + else colorsToByte((acc << 1).toByte, rem, f) + } + + private def lineToBytes(colors: List[Color], f: Color => Boolean): List[Byte] = + colors.sliding(8, 8).map(cs => colorsToByte(0.toByte, cs.padTo(8, Color(0, 0, 0, 0)), f)).toList + + private def storeLine(colors: Array[Color], f: Color => Boolean): ByteStreamState[String] = + writeBytes(lineToBytes(colors.toList, f).map(java.lang.Byte.toUnsignedInt)) + + @tailrec + private def storePixels( + lines: Vector[Array[Color]], + f: Color => Boolean, + acc: ByteStreamState[String] = emptyStream + ): ByteStreamState[String] = { + if (lines.isEmpty) acc + else storePixels(lines.tail, f, acc.flatMap(_ => storeLine(lines.head, f))) + } + + private val storeHeader: ByteStreamState[String] = { + (for { + _ <- writeString("Playdate IMG") + _ <- writeLENumber(0, 4) // Disable compression + } yield ()) + } + + private def storeCellHeader(surface: Surface): ByteStreamState[String] = { + (for { + _ <- writeLENumber(surface.width, 2) + _ <- writeLENumber(surface.height, 2) + _ <- writeLENumber(math.ceil(surface.width / 8.0).toInt, 2) + _ <- writeLENumber(0, 2) // No padding + _ <- writeLENumber(0, 2) // No padding + _ <- writeLENumber(0, 2) // No padding + _ <- writeLENumber(0, 2) // No padding + _ <- writeLENumber(0x03, 2) // Always enable transparency + } yield ()) + } + + final def storeImage(surface: Surface, os: OutputStream): Either[String, Unit] = { + val state = for { + _ <- storeHeader + _ <- storeCellHeader(surface) + _ <- storePixels(surface.getPixels(), color => math.max(math.max(color.r, color.g), color.b) >= 127) + _ <- storePixels(surface.getPixels(), color => color.a > 0) + } yield () + toOutputStream(state, os) + } +} diff --git a/image/shared/src/test/resources/alpha/pdi-2bit.pdi b/image/shared/src/test/resources/alpha/pdi-2bit.pdi new file mode 100644 index 0000000000000000000000000000000000000000..21c53f629ce694fef42fbace70e1d3f46b0cb7a3 GIT binary patch literal 25504 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 Date: Sat, 15 Jun 2024 16:01:32 +0100 Subject: [PATCH 2/2] Fix Java 8 compilation --- .../graphics/image/pdi/PdiImageReader.scala | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageReader.scala b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageReader.scala index 5b129df2..ebf3b695 100644 --- a/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageReader.scala +++ b/image/shared/src/main/scala/eu/joaocosta/minart/graphics/image/pdi/PdiImageReader.scala @@ -2,6 +2,8 @@ package eu.joaocosta.minart.graphics.image.pdi import java.io.InputStream +import scala.annotation.tailrec + import eu.joaocosta.minart.graphics.* import eu.joaocosta.minart.graphics.image.* import eu.joaocosta.minart.internal.* @@ -23,9 +25,26 @@ trait PdiImageReader extends ImageReader { data: CustomInputStream, width: Int, lineBytes: Int - ): Array[Boolean] = { - val bytes = data.readNBytes(lineBytes) - bytes.flatMap(b => bitsFromByte(b, 8)).take(width) + ): ParseResult[Array[Boolean]] = { + readBytes(lineBytes).map(_.flatMap(b => bitsFromByte(b.toByte, 8)).take(width)).run(data) + } + + @tailrec + private def loadBits( + data: CustomInputStream, + remainingLines: Int, + width: Int, + lineBytes: Int, + acc: Vector[Array[Boolean]] = Vector() + ): ParseResult[Vector[Array[Boolean]]] = { + if (isEmpty(data) || remainingLines == 0) Right(data -> acc) + else { + loadBitLine(data, width, lineBytes) match { + case Left(error) => Left(error) + case Right((remaining, line)) => + loadBits(remaining, remainingLines - 1, width, lineBytes, acc :+ line) + } + } } private def loadHeader(bytes: CustomInputStream): ParseResult[Header] = { @@ -57,31 +76,26 @@ trait PdiImageReader extends ImageReader { final def loadImage(is: InputStream): Either[String, RamSurface] = { val bytes = fromInputStream(is) - loadHeader(bytes).flatMap(_ => loadCellHeader(bytes)).map { case (data, cellHeader) => + loadHeader(bytes).flatMap(_ => loadCellHeader(bytes)).flatMap { case (data, cellHeader) => val emptyColor = Color(0, 0, 0, 0) val width = cellHeader.clipLeft + cellHeader.clipWidth + cellHeader.clipRight val leftPad = Array.fill(cellHeader.clipLeft)(emptyColor) val rightPad = Array.fill(cellHeader.clipRight)(emptyColor) - val centerColors = - (0 until cellHeader.clipHeight).map { _ => - loadBitLine(data, cellHeader.clipWidth, cellHeader.stride) - }.toVector - val centerMask = - (0 until cellHeader.clipHeight).map { _ => - loadBitLine(data, cellHeader.clipWidth, cellHeader.stride) - }.toVector - val centerPixels = centerColors.zip(centerMask).map { (colorLine, maskLine) => - colorLine.zip(maskLine).map { - case (_, false) => Color(0, 0, 0, 0) - case (false, true) => Color(0, 0, 0) - case (true, true) => Color(255, 255, 255) + for { + centerColors <- loadBits(data, cellHeader.clipHeight, cellHeader.clipWidth, cellHeader.stride) + centerMask <- loadBits(data, cellHeader.clipHeight, cellHeader.clipWidth, cellHeader.stride) + centerPixels = centerColors._2.zip(centerMask._2).map { (colorLine, maskLine) => + colorLine.zip(maskLine).map { + case (_, false) => Color(0, 0, 0, 0) + case (false, true) => Color(0, 0, 0) + case (true, true) => Color(255, 255, 255) + } } - } - val pixels = - Vector.fill(cellHeader.clipTop)(Array.fill(width)(emptyColor)) ++ - centerPixels.map(center => leftPad ++ center ++ rightPad) ++ - Vector.fill(cellHeader.clipBottom)(Array.fill(width)(emptyColor)) - new RamSurface(pixels) + pixels = + Vector.fill(cellHeader.clipTop)(Array.fill(width)(emptyColor)) ++ + centerPixels.map(center => leftPad ++ center ++ rightPad) ++ + Vector.fill(cellHeader.clipBottom)(Array.fill(width)(emptyColor)) + } yield (new RamSurface(pixels)) } } }