Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Playdate Image (PDI) support #498

Merged
merged 2 commits into from
Jun 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion docs/_docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

}
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package eu.joaocosta.minart.graphics.image.pdi

private[pdi] final case class Header(
magic: String,
compressed: Boolean
)
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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.*

/** 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
): 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] = {
(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)).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)
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)
}
}
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))
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ class ImageReaderSpec extends munit.FunSuite {
qoiTest("lausanne", 640, 480)
}

test("Load a PDI image") {
def pdiTest(dir: String, width: Int, height: Int): Unit =
testSize(
List(
Image.loadPdiImage(Resource(s"$dir/pdi-2bit.pdi"))
),
width,
height
)
pdiTest("alpha", 507, 200)
}

test("Load the same data from different formats (square image)") {
sameImage(
List(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,9 @@ class ImageWriterSpec extends munit.FunSuite {
roundtripTest(Resource("scala-rect/qoi-24bit.qoi"), qoi.QoiImageFormat.defaultFormat)
roundtripTest(Resource("lausanne/qoi-24bit.qoi"), qoi.QoiImageFormat.defaultFormat)
}

test("Write a PDI image") {
roundtripTest(Resource("alpha/pdi-2bit.pdi"), pdi.PdiImageFormat.defaultFormat)
}
}
}
Loading