简体   繁体   中英

Scala: Most concise conversion of a CSS color string to RGB integers

I am trying to get the RGB values of a CSS color string and wonder how good my code is:

object Color {
  def stringToInts(colorString: String): Option[(Int, Int, Int)] = {
    val trimmedColorString: String = colorString.trim.replaceAll("#", "")
    val longColorString: Option[String] = trimmedColorString.length match {
      // allow only strings with either 3 or 6 letters
      case 3 => Some(trimmedColorString.flatMap(character => s"$character$character"))
      case 6 => Some(trimmedColorString)
      case _ => None
    }
    val values: Option[Seq[Int]] = longColorString.map(_
      .foldLeft(Seq[String]())((accu, character) => accu.lastOption.map(_.toSeq) match {
        case Some(Seq(_, _)) => accu :+ s"$character" // previous value is complete => start with succeeding
        case Some(Seq(c)) => accu.dropRight(1) :+ s"$c$character" // complete the previous value
        case _ => Seq(s"$character") // start with an incomplete first value
      })
      .flatMap(hexString => scala.util.Try(Integer.parseInt(hexString, 16)).toOption)
      // .flatMap(hexString => try {
      //  Some(Integer.parseInt(hexString, 16))
      // } catch {
      //   case _: Exception => None
      // })
    )
    values.flatMap(values => values.size match {
      case 3 => Some((values.head, values(1), values(2)))
      case _ => None
    })
  }
}

// example:

println(Color.stringToInts("#abc")) // prints Some((170,187,204))

You may run that example on https://scastie.scala-lang.org

The parts of that code I am most unsure about are

  • the match in the foldLeft (is it a good idea to use string interpolation or can the code be written shorter without string interpolation?)
  • Integer.parseInt in conjunction with try (can I use a prettier alternative in Scala?) (solved thanks to excellent comment by Xavier Guihot)

But I expect most parts of my code to be improvable. I do not want to introduce new libraries in addition to com.itextpdf to shorten my code, but using com.itextpdf functions is an option. (The result of stringToInts is going to be converted into a new com.itextpdf.kernel.colors.DeviceRgb(...) , thus I have installed com.itextpdf anyway.)

Tests defining the expected function:

import org.scalatest.{BeforeAndAfterEach, FunSuite}

class ColorTest extends FunSuite with BeforeAndAfterEach {

  test("shorthand mixed case color") {
    val actual: Option[(Int, Int, Int)] = Color.stringToInts("#Fa#F")
    val expected = (255, 170, 255)
    assert(actual === Some(expected))
  }

  test("mixed case color") {
    val actual: Option[(Int, Int, Int)] = Color.stringToInts("#1D9a06")
    val expected = (29, 154, 6)
    assert(actual === Some(expected))
  }

  test("too short long color") {
    val actual: Option[(Int, Int, Int)] = Color.stringToInts("#1D9a6")
    assert(actual === None)
  }

  test("too long shorthand color") {
    val actual: Option[(Int, Int, Int)] = Color.stringToInts("#1D9a")
    assert(actual === None)
  }

  test("invalid color") {
    val actual: Option[(Int, Int, Int)] = Color.stringToInts("#1D9g06")
    assert(actual === None)
  }

}

I came up with this fun answer (untested); I guess the biggest help for you will be the use of sliding(2,2) instead of the foldLeft .

def stringToInts(colorString: String): Option[(Int, Int, Int)] = {
  val trimmedString: String => String = _.trim.replaceAll("#", "")

  val validString: String => Option[String] = s => s.length match {
    case 3 => Some(s.flatMap(c => s"$c$c"))
    case 6 => Some(s)
    case _ => None
  }

  val hex2rgb: String => List[Option[Int]] = _.sliding(2, 2).toList
    .map(hex => Try(Integer.parseInt(hex, 16)).toOption)

  val listOpt2OptTriple: List[Option[Int]] => Option[(Int, Int, Int)] = {
    case Some(r) :: Some(g) :: Some(b) :: Nil => Some(r, g, b)
    case _ => None
  }

  for {
    valid <- validString(trimmedString(colorString))
    rgb = hex2rgb(valid)
    answer <- listOpt2OptTriple(rgb)
  } yield answer
}

At the moment of writing this answer the other answers don't properly handle rgb() , rgba() and named colors cases. Color strings that start with hashes ( # ) are only a part of the deal.

As you have iText7 as a dependency and iText7 has a pdfHTML add-on which means the logic for parsing CSS colors obviously must be somewhere in iText7 and, more importantly, it must handle various range of CSS color cases. The question is only about finding the right place. Fortunately, this API is public and easy to use.

The method you are interested in is WebColors.getRGBAColor() from package com.itextpdf.kernel.colors which accepts a CSS color string a returns a 4-element array with R , G , B , A values (last one stands for alpha, ie transparency).

You can use those values to create a color right away (code in Java):

float[] rgbaColor = WebColors.getRGBAColor("#ababab");
Color color = new DeviceRgb(rgbaColor[0], rgbaColor[1], rgbaColor[2]);

In Scala it must be something like

val rgbaColor = WebColors.getRGBAColor("#ababab");
val color = new DeviceRgb(rgbaColor(0), rgbaColor(1), rgbaColor(2));

Here is a possible implementation of your function

def stringToInts(css: String): Option[(Int, Int, Int)] = {
  def cssColour(s: String): Int = {
    val v = Integer.parseInt(s, 16)

    if (s.length == 1) v*16 + v else v
  }

  val s = css.trim.replaceAll("#", "")
  val l = s.length/3

  if (l > 2 || l*3 != s.length) {
    None
  } else {
    Try{
      val res = s.grouped(l).map(cssColour).toSeq

      (res(0), res(1), res(2))
    }.toOption
  }
}

The implementation would be cleaner if it returned Option[List[Int]] or even Try[List[Int]] to preserve the error in the case of failure.

If you're looking for conciseness, perhaps this solution will do the job (at the expense of efficiency—more on that later):

import scala.util.Try

def parseLongForm(rgb: String): Try[(Int, Int, Int)] =
  Try {
    rgb.replace("#", "").
      grouped(2).toStream.filter(_.length == 2).
      map(Integer.parseInt(_, 16)) match { case Stream(r, g, b) => (r, g, b) }
  }

def parseShortForm(rgb: String): Try[(Int, Int, Int)] =
  parseLongForm(rgb.flatMap(List.fill(2)(_)))

def parse(rgb: String): Option[(Int, Int, Int)] =
  parseLongForm(rgb).orElse(parseShortForm(rgb)).toOption

In terms of conciseness is that every function here is effectively a one-liner (if that's something you're looking for right now).

The core is the function parseLongForm , which attempts to parse the long 6-character long form by:

  • removing the # character
  • grouping the characters in pairs
  • filtering out lone items (in case we have an odd number of characters)
  • parsing each pair
  • matching with the expected result to extract individual items

parseLongForm represents the possibility of failure with Try , which allows us to fail gracefully whenever parseInt or the pattern matching fails.

parse invokes parseLongForm and, if the result is a failure ( orElse ), invokes parseShortForm , which just tries the same approach after doubling each character.

It successfully passes the tests that you provided (kudos, that makes addressing the question much easier).

The main issue with this approach is that you would still try to parse the long form even if it can be clear from the beginning that it would not work. So, this is not recommended code if this could be a performance bottleneck for your use case. Another issue is that, although that's more or less hidden, we're using exceptions for flow control (which also hurts performance).

The nice things are conciseness and, I'd argue, readability (as I'd say that the code maps in a fairly straightforward fashion to the problem—but readability, of course, is by definition in the eye of the beholder).

You can find this solution on Scastie .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM