Skip to content

Commit

Permalink
Merge pull request #498 from JD557/playdate-image-support
Browse files Browse the repository at this point in the history
Add Playdate Image (PDI) support
  • Loading branch information
JD557 authored Jun 15, 2024
2 parents a1350e0 + 7787928 commit ada94d6
Show file tree
Hide file tree
Showing 11 changed files with 230 additions and 2 deletions.
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)
}
}
}

0 comments on commit ada94d6

Please sign in to comment.