diff --git a/modules/core/src/main/scala/tut/AnsiFilterStream.scala b/modules/core/src/main/scala/tut/AnsiFilterStream.scala index 510157f..0ebf923 100644 --- a/modules/core/src/main/scala/tut/AnsiFilterStream.scala +++ b/modules/core/src/main/scala/tut/AnsiFilterStream.scala @@ -68,4 +68,3 @@ class AnsiFilterStream(os: OutputStream) extends FilterOutputStream(os) { } } - diff --git a/modules/core/src/main/scala/tut/FileIO.scala b/modules/core/src/main/scala/tut/FileIO.scala index 68a0d00..6175ee3 100644 --- a/modules/core/src/main/scala/tut/FileIO.scala +++ b/modules/core/src/main/scala/tut/FileIO.scala @@ -43,18 +43,19 @@ object FileIO extends IMainPlatform /* scala version-specific */ { * @return Ending state of tut after processing */ def tut(in: File, out: File, opts: List[String]): IO[TutState] = - IO(new FileOutputStream(out)).using { outputStream => - IO(new AnsiFilterStream(outputStream)).using { filterStream => - IO(new Spigot(filterStream)).using { filterSpigot => + IO(new FileOutputStream(out)).using { outputstream => + IO(new ImageFilterStream(outputstream)).using { imageFilterStream => + IO(new AnsiFilterStream(imageFilterStream)).using { ansiFilterStream => + IO(new Spigot(ansiFilterStream)).using { filterSpigot => IO(new PrintStream(filterSpigot, true, Encoding)).using { printStream => IO(new OutputStreamWriter(printStream, Encoding)).using { streamWriter => IO(new PrintWriter(streamWriter)).using { printWriter => (for { interp <- newInterpreter(printWriter, iMainSettings(opts)) - state = TutState(false, Set(), false, interp, printWriter, filterSpigot, "", false, in, opts) + state = TutState(false, Set(), false, interp, imageFilterStream, printWriter, filterSpigot, "", false, in, opts) endSt <- Tut.file(in).exec(state) } yield endSt).withOut(printStream) - }}}}}} + }}}}}}} private[tut] def ls(dir: File): IO[List[File]] = IO(Option(dir.listFiles).fold(List.empty[File])(_.toList)) diff --git a/modules/core/src/main/scala/tut/ImageFilterStream.scala b/modules/core/src/main/scala/tut/ImageFilterStream.scala new file mode 100644 index 0000000..d6dda0f --- /dev/null +++ b/modules/core/src/main/scala/tut/ImageFilterStream.scala @@ -0,0 +1,146 @@ +package tut + +import java.io.{OutputStream, FilterOutputStream} +import java.nio.charset.StandardCharsets +import java.util.Base64 + +class ImageFilterStream(os: OutputStream) extends FilterOutputStream(os) { + import ImageFilterStream._ + + val F: State = State(_ => F) + + val T_B64: State = State(_ => T_B64) + val T_URL: State = State(_ => T_URL) + + val S: State = State { + case 27 => I0 + case _ => F + } + + val I0: State = State { + case ']' => I1 + case _ => F + } + + val I1: State = State { + case '1' => I2 + case _ => F + } + + val I2: State = State { + case '3' => I3 + case _ => F + } + + val I3: State = State { + case '3' => I4 + case _ => F + } + + val I4: State = State { + case '7' => I_B64 + case '8' => I_URL + case _ => F + } + + val I_B64: State = State { + case '\u0007' => T_B64 + case _ => I_B64 + } + + val I_URL: State = State { + case '\u0007' => T_URL + case _ => I_URL + } + + def get(n: Int): Option[Entry] = + unsafeEntries.drop(n).headOption + + def length: Int = unsafeEntries.length + + private var unsafeEntries: List[Entry] = Nil + private var stack: List[Int] = Nil + private var state: State = S // Start + + private def decodeDataString(input: List[Int]): String = + new String( + stack.reverse.drop(6).map(_.toByte).toArray, + StandardCharsets.UTF_8) + + private def parseKeyValuePairs(input: String): (Map[String, String], Option[String]) = { + val index = input.lastIndexOf(':') + val (input0, extra) = if (index > 0) { + val (prefix, suffix) = input.splitAt(index) + prefix -> Some(suffix.drop(1)) + } else input -> None + + input0.split(";") + .flatMap(_.split("=", 2).toList match { + case head :: tail :: Nil => Some(head -> tail) + case _ => None + }) + .toMap -> extra + } + + override def write(n: Int): Unit = + state.apply(n) match { + + case F => + stack.foldRight(())((c, _) => super.write(c)) + super.write(n) + stack = Nil + state = S + + case T_URL => + val (map, extra) = parseKeyValuePairs(decodeDataString(stack)) + val maybeEntry = map.get("url").map(url => + Entry.URL(url, map.get("alt"))) + unsafeEntries = maybeEntry.toList ::: unsafeEntries + stack.foldRight(())((c, _) => super.write(c)) + super.write(n) + stack = Nil + state = S + + case T_B64 => + val (map, extra) = parseKeyValuePairs(decodeDataString(stack)) + val maybeEntry = + for { + _ <- map.get("File") + data <- extra + } yield Entry.Base64(data, map.get("alt")) + unsafeEntries = maybeEntry.toList ::: unsafeEntries + stack.foldRight(())((c, _) => super.write(c)) + super.write(n) + stack = Nil + state = S + + case s => + stack = n :: stack + state = s + + } + +} + +object ImageFilterStream { + + private lazy val decoder: Base64.Decoder = Base64.getDecoder + + final case class State(apply: Int => State) + + sealed trait Entry + object Entry { + final case class URL(url: String, alt: Option[String]) extends Entry + final case class Base64(data: String, alt: Option[String]) extends Entry { + lazy val decoded: Array[Byte] = decoder.decode(data) + + lazy val fileExtension: String = decoded.take(8).toList match { + case List(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) => "png" + case List(0xFF, 0xD8, 0xFF, 0xDB | 0xE0 | 0xE1, _, _, _, _) => "jpg" + case List(0x47, 0x49, 0x46, 0x38, 0x37 | 0x39, 0x61, _, _) => "gif" + case List(0x42, 0x4D, _, _, _, _, _, _) => "bmp" + case _ => "img" // ¯\_(ツ)_/¯ + } + } + } +} diff --git a/modules/core/src/main/scala/tut/Tut.scala b/modules/core/src/main/scala/tut/Tut.scala index dd75b98..a431a24 100644 --- a/modules/core/src/main/scala/tut/Tut.scala +++ b/modules/core/src/main/scala/tut/Tut.scala @@ -104,9 +104,26 @@ object Tut { private def out(text: String): Tut[Unit] = for { s <- Tut.state - _ <- (s.mods(Invisible) || s.mods(Evaluated) || s.mods(Passthrough)).unlessM(IO { s.pw.println(text); s.pw.flush() }.liftIO[Tut]) + _ <- (s.mods(Invisible) || s.mods(Evaluated) || s.mods(Passthrough)).unlessM(IO { s.pw.println(unsafeImagery(text, s)); s.pw.flush() }.liftIO[Tut]) } yield () + val TutImageTag = raw"!\[([^\]]+)\]\(tut\:(\d+)\)".r + import ImageFilterStream.Entry + + private def unsafeImagery(text: String, s: TutState): String = { + TutImageTag.replaceAllIn(text, _ match { + case TutImageTag(alt, indexStr) => + val res = s.ifs.get(indexStr.toInt).map(_ match { + case Entry.URL(urlStr, maybeAlt) => + s"![${maybeAlt getOrElse alt}]($urlStr)" + case entry @ Entry.Base64(dataStr, maybeAlt) => + val lol = "it's a " + entry.fileExtension + s"![${maybeAlt getOrElse alt}]($lol)" + }) + res getOrElse s"<>" + }) + } + private def success: Tut[Unit] = for { s <- Tut.state diff --git a/modules/core/src/main/scala/tut/TutState.scala b/modules/core/src/main/scala/tut/TutState.scala index fa96005..a335d32 100644 --- a/modules/core/src/main/scala/tut/TutState.scala +++ b/modules/core/src/main/scala/tut/TutState.scala @@ -8,6 +8,7 @@ final case class TutState( mods: Set[Modifier], needsNL: Boolean, imain: IMain, + ifs: ImageFilterStream, pw: PrintWriter, spigot: Spigot, partial: String, diff --git a/modules/tests/src/sbt-test/tut/test-15-images/build.sbt b/modules/tests/src/sbt-test/tut/test-15-images/build.sbt new file mode 100644 index 0000000..8681871 --- /dev/null +++ b/modules/tests/src/sbt-test/tut/test-15-images/build.sbt @@ -0,0 +1,15 @@ +enablePlugins(TutPlugin) + +scalaVersion := sys.props("scala.version") + +resolvers += Resolver.bintrayRepo("cibotech", "public") +libraryDependencies += "com.cibo" %% "evilplot" % "0.4.0" + +lazy val check = TaskKey[Unit]("check") + +check := { + val expected = IO.readLines(file("expect.md")) + val actual = IO.readLines(crossTarget.value / "tut"/ "test.md") + if (expected != actual) + sys.error("Output doesn't match expected: \n" + actual.mkString("\n")) +} diff --git a/modules/tests/src/sbt-test/tut/test-15-images/expect.md b/modules/tests/src/sbt-test/tut/test-15-images/expect.md new file mode 100644 index 0000000..3cb458f --- /dev/null +++ b/modules/tests/src/sbt-test/tut/test-15-images/expect.md @@ -0,0 +1 @@ +Images diff --git a/modules/tests/src/sbt-test/tut/test-15-images/project/plugins.sbt b/modules/tests/src/sbt-test/tut/test-15-images/project/plugins.sbt new file mode 100644 index 0000000..bbe56cd --- /dev/null +++ b/modules/tests/src/sbt-test/tut/test-15-images/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.tpolecat" % "tut-plugin" % sys.props("project.version")) diff --git a/modules/tests/src/sbt-test/tut/test-15-images/src/main/tut/test.md b/modules/tests/src/sbt-test/tut/test-15-images/src/main/tut/test.md new file mode 100644 index 0000000..1b9654a --- /dev/null +++ b/modules/tests/src/sbt-test/tut/test-15-images/src/main/tut/test.md @@ -0,0 +1,28 @@ +Images + +```tut +println("\033]1338;url=https://media.giphy.com/media/10RhccNxPSaglW/giphy.gif;alt=keycat\u0007") +``` + +```tut +import com.cibo.evilplot.plot._ +import com.cibo.evilplot.plot.aesthetics.DefaultTheme._ +import com.cibo.evilplot.numeric.Point +import java.io.File + +val data = Seq.tabulate(100) { i => + Point(i.toDouble, scala.util.Random.nextDouble()) +} +val bufferedImage = ScatterPlot(data).render().asBufferedImage +``` + +```tut:invisible +val out = java.util.Base64.getEncoder().wrap(Console.out) +print("\033]1337;File=blah;foo=whatever:") +javax.imageio.ImageIO.write(bufferedImage, "png", out) +print("\u0007") +``` + +wahoo ![wahoo](tut:1) + +plot goes here ![lol](tut:0), hopefully diff --git a/modules/tests/src/sbt-test/tut/test-15-images/test b/modules/tests/src/sbt-test/tut/test-15-images/test new file mode 100644 index 0000000..acd5f3c --- /dev/null +++ b/modules/tests/src/sbt-test/tut/test-15-images/test @@ -0,0 +1,2 @@ +> tut +> check