Skip to main content

src/internal/encoder/quoted_printable.gleam

//// Encodes quoted-printable strings according to RFC 2045
//// and RFC 2047.
////
//// See the following links for reference:
//// - <https://tools.ietf.org/html/rfc2045#section-6.7>
//// - <https://tools.ietf.org/html/rfc2047>

import gleam/bit_array
import gleam/bool
import gleam/list
import gleam/order
import gleam/result
import gleam/string

import internal/encoder/encoding.{type Rfc, Rfc2047}

/// Estimates the quoted printable encoded size of a string in bytes,
/// taking the desired RFC constraints into account.
pub fn estimate_encoded_size(
  of string: String,
  enforce rfc: Rfc,
) -> Result(Int, encoding.EncoderError) {
  string
  |> string.split("")
  |> list.fold(0, fn(size, character) {
    let character_size = string.byte_size(character)

    case should_encode_character(character, rfc) {
      True -> size + { 3 * character_size }
      False -> size + character_size
    }
  })
  |> Ok
}

/// Quoted printable encode a string, taking the desired RFC
/// constraints and the preferred line size into account, and
/// pretending to start the first line at the passed start position.
pub fn encode_string(
  encode string: String,
  enforce rfc: Rfc,
  start position: Int,
  preferred_size preferred_size: Int,
) -> Result(List(String), encoding.EncoderError) {
  string
  |> string.split("")
  |> do_encode_string(rfc, preferred_size, #(position, []))
  |> result.map(list.reverse)
}

fn do_encode_string(
  characters: List(String),
  rfc: Rfc,
  preferred_size: Int,
  accumulator: #(Int, List(String)),
) -> Result(List(String), encoding.EncoderError) {
  let #(used_size, lines) = accumulator

  case characters {
    [] -> Ok(lines)
    [character, ..other_characters] -> {
      let force_encoding =
        is_whitespace(character) && list.is_empty(other_characters)

      let encoded_character = encode_character(character, rfc, force_encoding)
      let encoded_character_size = string.byte_size(encoded_character)

      case lines {
        [] -> #(encoded_character_size, [encoded_character])
        [first_line, ..other_lines]
          if { used_size + encoded_character_size < preferred_size }
          || {
            other_characters == []
            && used_size + encoded_character_size == preferred_size
          }
        -> #(used_size + encoded_character_size, [
          first_line <> encoded_character,
          ..other_lines
        ])
        [first_line, ..other_lines] -> #(encoded_character_size, [
          encoded_character,
          first_line <> "=",
          ..other_lines
        ])
      }
      |> do_encode_string(other_characters, rfc, preferred_size, _)
    }
  }
}

fn should_encode_character(character: String, rfc: Rfc) -> Bool {
  character != "\t"
  && {
    string.compare(character, "\u{20}") == order.Lt
    || string.compare(character, "\u{7f}") == order.Gt
    || string.compare(character, "=") == order.Eq
    || { rfc == Rfc2047 && string.contains("()<>@,;:\"/[]?._", character) }
  }
}

fn is_whitespace(character: String) -> Bool {
  string.compare(character, "\t") == order.Eq
  || string.compare(character, " ") == order.Eq
}

fn encode_character(
  character: String,
  rfc: Rfc,
  force_encoding: Bool,
) -> String {
  let should_encode = should_encode_character(character, rfc) || force_encoding

  use <- bool.guard(when: !should_encode, return: character)
  do_encode_bytes(bit_array.from_string(character), [])
}

fn do_encode_bytes(bytes: BitArray, accumulator: List(String)) -> String {
  case bytes {
    <<byte:bytes-size(1), other_bytes:bytes>> ->
      do_encode_bytes(other_bytes, [
        "=" <> bit_array.base16_encode(byte),
        ..accumulator
      ])
    <<_:bits>> -> accumulator |> list.reverse() |> string.join("")
  }
}