packages feed

mjml-0.1.0.0: src/Text/Mjml/Js.hs

-- | The handful of JavaScript number semantics MJML's output depends on.
--
-- MJML computes widths with JavaScript numbers and prints them through
-- template strings, so class names such as @mj-column-per-33-333333333333336@
-- and widths such as @300px@ are whatever @Number.prototype.toString@,
-- @parseInt@ and @parseFloat@ make of the arithmetic. Reproducing those
-- functions exactly is what lets this implementation emit the same HTML.
module Text.Mjml.Js (
    showNumber,
    parseIntJs,
    parseFloatJs,
    toFixedNumber,
    roundJs,
    isNaNJs,
) where

import Data.Char (isDigit, isSpace)
import Data.Ratio ((%))
import Data.Text (Text)
import Data.Text qualified as T
import Numeric (floatToDigits)


-- | @String(x)@ for a JavaScript number.
showNumber :: Double -> Text
showNumber x
    | isNaN x = "NaN"
    | isInfinite x = if x > 0 then "Infinity" else "-Infinity"
    | x == 0 = "0"
    | x < 0 = "-" <> showNumber (negate x)
    | x < 1e21 && x == fromInteger (truncate x) = T.pack (show (truncate x :: Integer))
    | otherwise =
        let (ds, n) = floatToDigits 10 x
            k = length ds
            digits = T.pack (map toDigitChar ds)
         in if
                | k <= n && n <= 21 -> digits <> T.replicate (n - k) "0"
                | 0 < n && n <= 21 -> T.take n digits <> "." <> T.drop n digits
                | -6 < n && n <= 0 -> "0." <> T.replicate (negate n) "0" <> digits
                | otherwise ->
                    let e = n - 1
                        sign = if e < 0 then "-" else "+"
                        mantissa =
                            if k == 1
                                then digits
                                else T.take 1 digits <> "." <> T.drop 1 digits
                     in mantissa <> "e" <> sign <> T.pack (show (abs e))
  where
    toDigitChar d = toEnum (fromEnum '0' + d)


-- | @parseInt(s, 10)@. 'NaN' when no digits lead the string.
parseIntJs :: Text -> Double
parseIntJs input =
    let s = T.dropWhile isSpace input
        (sign, rest) = splitSign s
        ds = T.takeWhile isDigit rest
     in if T.null ds
            then 0 / 0
            else sign * fromInteger (decimalValue ds)


-- | @parseFloat(s)@. 'NaN' when no number leads the string.
parseFloatJs :: Text -> Double
parseFloatJs input =
    let s = T.dropWhile isSpace input
        (sign, rest) = splitSign s
        intPart = T.takeWhile isDigit rest
        afterInt = T.drop (T.length intPart) rest
        (fracPart, afterFrac) = case T.uncons afterInt of
            Just ('.', r) -> let f = T.takeWhile isDigit r in (f, T.drop (T.length f) r)
            _ -> ("", afterInt)
        expPart = case T.uncons afterFrac of
            Just (e, r)
                | e == 'e' || e == 'E' ->
                    let (esign, r') = splitSign r
                        eds = T.takeWhile isDigit r'
                     in if T.null eds then Nothing else Just (round esign * decimalValue eds)
            _ -> Nothing
     in if T.null intPart && T.null fracPart
            then 0 / 0
            else
                let mantissa =
                        decimalValue intPart * 10 ^ T.length fracPart + decimalValue fracPart
                    scale = toInteger (T.length fracPart) - maybe 0 id expPart
                    value =
                        if scale >= 0
                            then mantissa % (10 ^ scale)
                            else (mantissa * 10 ^ negate scale) % 1
                 in sign * fromRational value


-- | The value of a run of decimal digits.
decimalValue :: Text -> Integer
decimalValue = T.foldl' (\acc c -> acc * 10 + toInteger (fromEnum c - fromEnum '0')) 0


splitSign :: Text -> (Double, Text)
splitSign s = case T.uncons s of
    Just ('-', r) -> (-1, r)
    Just ('+', r) -> (1, r)
    _ -> (1, s)


-- | @Number(x.toFixed(digits))@: round to a fixed number of decimals the way
-- JavaScript does (ties go to the larger value), then read the result back as
-- a number.
toFixedNumber :: Int -> Double -> Double
toFixedNumber digits x
    | isNaN x || isInfinite x = x
    | otherwise =
        let scale = 10 ^ digits :: Integer
            exact = toRational x * fromInteger scale
            n = floor (exact + 1 / 2) :: Integer
         in fromRational (n % scale)


-- | @Math.round(x)@: halves round towards positive infinity.
roundJs :: Double -> Double
roundJs x
    | isNaN x || isInfinite x = x
    | otherwise =
        let r = fromInteger (floor x)
         in if x - r >= 0.5 then r + 1 else r


isNaNJs :: Double -> Bool
isNaNJs = isNaN