///|
// asciidoctor-pdf 2.3.27's ThemeLoader (lib/asciidoctor/pdf/theme_loader.rb):
// theme files are loaded, the themes they extend first, and their nested
// keys flattened into underscore keys, each value evaluated in order
// (`$variable` references see the keys loaded so far; arithmetic and units
// are resolved; colors normalized).

///|
/// Where the bundled themes live (Ruby's `ThemeLoader::ThemesDir`, the
/// gem's `data/themes`). They are bundled in this package, so this is a
/// virtual directory: `Files` serves the bundled themes from it.
pub let themes_dir : String = "/asciidoctor-pdf/data/themes"

///|
/// Where the bundled fonts live (`ThemeLoader::FontsDir`, the gem's
/// `data/fonts`): the directory `GEM_FONTS_DIR` stands for. Virtual too:
/// the converter maps it to the directory holding the fonts.
pub let fonts_dir : String = "/asciidoctor-pdf/data/fonts"

///|
/// `BaseThemePath`
pub let base_theme_path : String = themes_dir + "/base-theme.yml"

///|
/// The loaded theme (Ruby's `ThemeData`): flattened keys and their values,
/// in the order they were first set. As in Ruby, the directory the theme
/// was loaded from is a key too (`__dir__`, see `Theme::dir`).
pub struct Theme {
  table : Map[String, Value]
}

///|
pub fn Theme::new() -> Theme {
  { table: Map([]), }
}

///|
/// The directory the theme was loaded from (`__dir__`), which relative
/// asset paths resolve against: the bundled themes' when unset.
pub fn Theme::dir(self : Theme) -> String {
  match self.table.get("__dir__") {
    Some(Str(dir)) => dir
    _ => themes_dir
  }
}

///|
pub fn Theme::set_dir(self : Theme, dir : String) -> Unit {
  self.table["__dir__"] = Str(dir)
}

///|
/// The value of a key; None when the key is absent (a key set to nil is
/// `Some(Null)`, as `respond_to?` sees it).
pub fn Theme::get(self : Theme, key : String) -> Value? {
  self.table.get(key)
}

///|
/// The value of a key, nil when absent (`theme[key]`).
#alias("_[_]")
pub fn Theme::at(self : Theme, key : String) -> Value {
  self.table.get(key).unwrap_or(Null)
}

///|
#alias("_[_]=_")
pub fn Theme::set(self : Theme, key : String, value : Value) -> Unit {
  self.table[key] = value
}

///|
pub fn Theme::has(self : Theme, key : String) -> Bool {
  self.table.contains(key)
}

///|
pub fn Theme::copy(self : Theme) -> Theme {
  { table: self.table.copy(), }
}

///|
/// The keys in the order they were first set.
pub fn Theme::keys(self : Theme) -> Array[String] {
  self.table.keys().collect()
}

///|
/// How the loader reads files: an absolute path to its text, or None when
/// there is no such file. The bundled themes are served before it is asked.
pub(all) struct Files {
  read : (String) -> String?
  /// the working directory, for relative theme paths (`Dir.pwd`)
  cwd : String
  /// the home directory, for `~` (`File.expand_path`)
  home : String
}

///|
pub fn Files::new(
  read? : (String) -> String? = _ => None,
  cwd? : String = "/",
  home? : String = "/",
) -> Files {
  { read, cwd, home, }
}

///|
/// A file's text: a bundled theme, else from `read`.
fn Files::read_file(self : Files, path : String) -> String raise ThemeError {
  if dirname(path) == themes_dir {
    match bundled_theme(basename(path)) {
      Some(text) => return text
      None => ()
    }
  }
  match (self.read)(path) {
    Some(text) => text
    None => raise ThemeError("No such file or directory @ rb_sysopen - \{path}")
  }
}

///|
/// The names of the bundled themes (`BundledThemeNames`).
pub fn bundled_theme_names() -> Array[String] {
  bundled_themes.map(t => t.0[:t.0.length() - 10].to_owned())
}

///|
fn bundled_theme(file : String) -> String? {
  for t in bundled_themes {
    if t.0 == file {
      return Some(t.1)
    }
  }
  None
}

// ---------------------------------------------------------------------------
// paths

///|
/// `File.dirname`
fn dirname(path : String) -> String {
  match path.rev_find("/") {
    Some(0) => "/"
    Some(i) => path[:i].to_owned()
    None => "."
  }
}

///|
/// `File.basename`
fn basename(path : String) -> String {
  match path.rev_find("/") {
    Some(i) => path[i + 1:].to_owned()
    None => path
  }
}

///|
/// Normalize `.` and `..` segments of an absolute path.
fn normalize(path : String) -> String {
  let segs : Array[String] = []
  for s in path.split("/") {
    let s = s.to_owned()
    if s == "" || s == "." {
      continue
    } else if s == ".." {
      ignore(segs.pop())
    } else {
      segs.push(s)
    }
  }
  "/" + segs.join("/")
}

///|
/// `File.absolute_path(path, base)`: relative to `base` (itself relative
/// to the working directory), no `~` expansion.
fn absolute_path(files : Files, path : String, base : String) -> String {
  if path.has_prefix("/") {
    return normalize(path)
  }
  let base = if base.has_prefix("/") { base } else { files.cwd + "/" + base }
  normalize(base + "/" + path)
}

///|
/// `File.expand_path(path, base)`: as `absolute_path`, with `~` for the
/// home directory.
fn expand_path(files : Files, path : String, base? : String) -> String {
  if path == "~" || path.has_prefix("~/") {
    return normalize(files.home + path[1:].to_owned())
  }
  let base = match base {
    Some(b) => expand_path(files, b)
    None => files.cwd
  }
  absolute_path(files, path, base)
}

///|
/// `ThemeLoader.resolve_theme_file`: the theme's path and directory. A name
/// ending in `.yml` is a path (relative to `theme_dir`, else the working
/// directory); another is `-theme.yml` in `theme_dir` (default: the
/// bundled themes).
pub fn resolve_theme_file(
  files : Files,
  theme_name : String?,
  theme_dir : String?,
) -> (String, String) {
  match theme_name {
    Some(name) if name.has_suffix(".yml") =>
      match theme_dir {
        Some(dir) => {
          let dir = expand_path(files, dir)
          (absolute_path(files, name, dir), dir)
        }
        None => {
          let path = expand_path(files, name)
          (path, dirname(path))
        }
      }
    _ => {
      let dir = match theme_dir {
        Some(dir) => expand_path(files, dir)
        None => themes_dir
      }
      let name = theme_name.unwrap_or("default")
      (absolute_path(files, dir + "/" + name + "-theme.yml", files.cwd), dir)
    }
  }
}

///|
/// `ThemeLoader.resolve_theme_asset`
pub fn resolve_theme_asset(
  files : Files,
  asset_path : String,
  theme_dir? : String,
) -> String {
  absolute_path(files, asset_path, theme_dir.unwrap_or(themes_dir))
}

// ---------------------------------------------------------------------------
// loading

///|
/// `ThemeLoader.load_base_theme`: the base theme is loaded as it is, with
/// no processing (its keys are already flat).
pub fn load_base_theme(files : Files) -> Theme raise ThemeError {
  let data = safe_load(files.read_file(base_theme_path))
  let theme = Theme::new()
  match data {
    Hash(map, ..) =>
      for k, v in map {
        theme[k] = v
      }
    _ => ()
  }
  theme.set_dir(themes_dir)
  theme
}

///|
/// `ThemeLoader.load_theme`: the named (or `.yml` path) theme, or the
/// default theme; a theme outside the bundled themes gets the defaults of
/// a theme that extends nothing (left-aligned black text in Courier...).
pub fn load_theme(
  files : Files,
  theme_name? : String,
  theme_dir? : String,
) -> Theme raise ThemeError {
  let (theme_path, theme_dir) = resolve_theme_file(files, theme_name, theme_dir)
  if theme_path == base_theme_path {
    return load_base_theme(files)
  }
  let initial = Theme::new()
  initial["base_font_size"] = Int(12)
  let theme = load_file_into(
    files,
    theme_path,
    initial,
    Some(theme_dir),
    Map([]),
  )
  if dirname(theme_path) != themes_dir {
    let default = (key : String, value : Value) => {
      if !theme[key].truthy() {
        theme[key] = value
      }
    }
    default("base_text_align", Str("left"))
    default("base_line_height", Int(1))
    default("base_font_color", Str("000000"))
    let codespan = if theme["codespan_font_family"].truthy() {
      theme["codespan_font_family"]
    } else {
      Str("Courier")
    }
    default("code_font_family", codespan)
    default("conum_font_family", codespan)
    let heading_font_family = theme["heading_font_family"]
    if heading_font_family.truthy() {
      default("abstract_title_font_family", heading_font_family)
      default("sidebar_title_font_family", heading_font_family)
    }
  }
  theme.set_dir(theme_dir)
  theme
}

///|
/// `HexColorEntryRx`:
/// `^(? *\p{Graph}+): +(?!null$)(?["']?)(?#)?(?\h\h\h\h{0,3})\k *(?:#.*)?$`
/// — a line assigning a hex color (3 to 6 hex digits, maybe quoted, maybe
/// with `#`), rewritten as `k: 'v'` when it had `#` or the key ends in
/// `color`, else `k: v`, so YAML reads neither a comment nor a number.
fn protect_hex_color(line : String) -> String {
  let chars : Array[Char] = line.iter().collect()
  let n = chars.length()
  // the key: spaces, then printable non-space characters; the separator is
  // the last `: +` whose remainder matches (the key is greedy)
  let mut i = 0
  while i < n && chars[i] == ' ' {
    i += 1
  }
  let key_start = i
  let is_graph = (c : Char) => {
    c.to_int() > 0x20 &&
    c.to_int() != 0x7F &&
    !(c.to_int() >= 0x80 && c.is_whitespace())
  }
  let mut key_end = key_start
  while key_end < n && is_graph(chars[key_end]) {
    key_end += 1
  }
  // try each `:` inside the graph run, the rightmost first
  let mut colon = key_end - 1
  while colon > key_start {
    if chars[colon] == ':' && colon + 1 < n && chars[colon + 1] == ' ' {
      match match_hex_value(chars, colon + 1) {
        Some((hash, v)) => {
          let k = String::from_array(chars[:colon])
          let quote = hash || k.has_suffix("color")
          return if quote { "\{k}: '\{v}'" } else { "\{k}: \{v}" }
        }
        None => ()
      }
    }
    colon -= 1
  }
  line
}

///|
/// ` +(?!null$)(?["']?)(?#)?(?\h{3,6})\k *(?:#.*)?$` from
/// `start` (the spaces after the colon): whether it had `#`, and the digits.
fn match_hex_value(chars : Array[Char], start : Int) -> (Bool, String)? {
  let n = chars.length()
  let mut i = start
  if i >= n || chars[i] != ' ' {
    return None
  }
  while i < n && chars[i] == ' ' {
    i += 1
  }
  // (?!null$)
  if String::from_array(chars[i:]) == "null" {
    return None
  }
  let is_hex = (c : Char) => {
    is_digit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
  }
  // with and without the quote (the quote is optional: `["']?` backtracks)
  let quotes : Array[Char?] = if i < n && (chars[i] == '"' || chars[i] == '\'') {
    [Some(chars[i]), None]
  } else {
    [None]
  }
  for q in quotes {
    let mut j = i + (if q is Some(_) { 1 } else { 0 })
    let hash = j < n && chars[j] == '#'
    let hash_options = if hash { [true, false] } else { [false] }
    for with_hash in hash_options {
      let v_start = j + (if with_hash { 1 } else { 0 })
      // \h{3,6}, greedy, backtracking to 3
      let mut v_end = v_start
      while v_end < n && v_end - v_start < 6 && is_hex(chars[v_end]) {
        v_end += 1
      }
      while v_end - v_start >= 3 {
        let mut k = v_end
        let quote_ok = match q {
          Some(c) =>
            if k < n && chars[k] == c {
              k += 1
              true
            } else {
              false
            }
          None => true
        }
        if quote_ok {
          while k < n && chars[k] == ' ' {
            k += 1
          }
          if k == n || chars[k] == '#' {
            return Some((with_hash, String::from_array(chars[v_start:v_end])))
          }
        }
        v_end -= 1
      }
    }
    j = 0
    ignore(j)
  }
  None
}

///|
/// `ThemeLoader.load_file filename, nil, theme_dir`: a theme file loaded
/// by itself (the themes it extends first), without the defaults
/// `load_theme` adds.
pub fn load_file(
  files : Files,
  filename : String,
  theme_dir? : String,
) -> Theme raise ThemeError {
  load_file_into(files, filename, Theme::new(), theme_dir, Map([]))
}

///|
/// `ThemeLoader.load_file`: load a theme file into `theme` (the themes it
/// extends first), returning the theme data (which extending `base`
/// replaces). `loaded` holds the files loaded so far (`__loaded__`).
fn load_file_into(
  files : Files,
  filename : String,
  theme : Theme,
  theme_dir : String?,
  loaded : Map[String, Bool],
) -> Theme raise ThemeError {
  let text = files.read_file(filename)
  // newline: :universal
  let text = text
    .replace_all(old="\r\n", new="\n")
    .replace_all(old="\r", new="\n")
  let text = if dirname(filename) == themes_dir {
    text
  } else {
    let lines = text
      .split("\n")
      .map(l => protect_hex_color(l.to_owned()))
      .collect()
    lines.join("\n")
  }
  let data = safe_load(text) catch {
    ThemeError(message) => raise ThemeError("\{filename}: \{message}")
  }
  loaded[filename] = true
  let mut theme = theme
  let data = match data {
    Hash(map, ..) => {
      match map.get("extends") {
        Some(extends) => {
          map.remove("extends")
          let paths = match extends {
            Array(items) => items
            Null => []
            other => [other]
          }
          for item in paths {
            let mut path = item.to_ruby_s()
            let force = path.has_suffix(" !important")
            if force {
              path = path[:path.length() - 11].to_owned()
            }
            if path == "base" {
              if !loaded.contains("base") || force {
                loaded["base"] = true
                let merged = theme.copy()
                let base = load_base_theme(files)
                for k, v in base.table {
                  merged[k] = v
                }
                theme = merged
              }
              continue
            }
            let (extend_path, extend_dir) = if bundled_theme_names().contains(
                path,
              ) {
              resolve_theme_file(files, Some(path), Some(themes_dir))
            } else if path.has_prefix("./") {
              resolve_theme_file(files, Some(path), Some(dirname(filename)))
            } else {
              resolve_theme_file(files, Some(path), theme_dir)
            }
            if !loaded.contains(extend_path) || force {
              loaded[extend_path] = true
              theme = load_file_into(
                files,
                extend_path,
                theme,
                Some(extend_dir),
                loaded,
              )
            }
          }
        }
        None => ()
      }
      Some(map)
    }
    _ => None
  }
  match data {
    Some(map) => load_map(map, theme)
    None => ()
  }
  theme
}

///|
/// Warnings met while loading (Ruby logs them): unknown variables,
/// deprecated categories. Cleared by `clear_warnings`.
pub let warnings : Array[String] = []

///|
pub fn clear_warnings() -> Unit {
  warnings.clear()
}

///|
/// `ThemeLoader#load`: process the entries of a (parsed) theme into
/// `theme`, top-level keys normalized.
pub fn load_map(
  map : Map[String, Value],
  theme : Theme,
) -> Unit raise ThemeError {
  for key, val in map {
    process_entry(key, val, theme, normalize_key=true)
  }
}

///|
/// Load theme YAML text into `theme` (or a new theme) without `extends`
/// or the hex color protection (`ThemeLoader.new.load YAML.safe_load text`).
pub fn load_text(text : String, theme? : Theme) -> Theme raise ThemeError {
  let theme = match theme {
    Some(t) => t
    None => Theme::new()
  }
  match safe_load(text) {
    Hash(map, ..) => load_map(map, theme)
    _ => ()
  }
  theme
}

///|
/// `DeprecatedCategoryKeys`
let deprecated_category_keys : Array[(String, String)] = [
  ("blockquote", "quote"),
  ("key", "kbd"),
  ("literal", "codespan"),
  ("outline_list", "list"),
]

///|
fn deprecated_category(key : String) -> String? {
  for entry in deprecated_category_keys {
    if entry.0 == key {
      return Some(entry.1)
    }
  }
  None
}

///|
/// `DeprecatedKeys`
fn deprecated_key(key : String) -> String? {
  if key == "table_caption_side" {
    return Some("table_caption_end")
  }
  let prefixes = [
    "base", "heading", "heading_h1", "heading_h2", "heading_h3", "heading_h4", "heading_h5",
    "heading_h6", "title_page", "abstract", "abstract_title", "admonition_label",
    "sidebar_title", "toc_title",
  ]
  for p in prefixes {
    if key == p + "_align" {
      return Some(p + "_text_align")
    }
  }
  None
}

///|
fn tr_dash(s : String) -> String {
  if s.contains("-") {
    s.replace_all(old="-", new="_")
  } else {
    s
  }
}

///|
/// `ThemeLoader#process_entry`
fn process_entry(
  key : String,
  val : Value,
  data : Theme,
  normalize_key? : Bool = false,
) -> Unit raise ThemeError {
  let key = if normalize_key { tr_dash(key) } else { key }
  if key == "font" {
    match val {
      Hash(map, ..) =>
        for subkey, subval in map {
          if subkey == "catalog" || subkey == "fallbacks" {
            process_entry("font_\{subkey}", subval, data)
          }
        }
      _ => ()
    }
  } else if key == "font_catalog" {
    data[key] = match val {
      Hash(map, ..) => {
        let map = map.copy()
        let merge = map.get("merge").map(v => v.truthy()).unwrap_or(false)
        map.remove("merge")
        let accum : Map[String, Value] = if merge {
          match data[key] {
            Hash(existing, ..) => existing.copy()
            _ => Map([])
          }
        } else {
          Map([])
        }
        for name, styles in map {
          let styles = match styles {
            Str(path) => {
              let m : Map[String, Value] = Map([])
              m["*"] = Str(path)
              Hash(m, symbols=false)
            }
            other => other
          }
          match styles {
            Hash(styles, ..) => {
              let subaccum : Map[String, Value] = Map([])
              for style, path in styles {
                let mut path = match path {
                  Str(p) => p
                  other =>
                    raise ThemeError(
                      "undefined method 'start_with?' for \{other.dump()}",
                    )
                }
                if path.has_prefix("GEM_FONTS_DIR") && path.length() > 13 {
                  path = fonts_dir + path[13:].to_owned()
                }
                let expanded = expand_vars(path, data)
                match style {
                  "*" =>
                    for s in ["normal", "bold", "italic", "bold_italic"] {
                      subaccum[s] = expanded
                    }
                  "regular" => subaccum["normal"] = expanded
                  _ => subaccum[style] = expanded
                }
              }
              accum[name] = Hash(subaccum, symbols=false)
            }
            // not a hash: ignored
            _ => ()
          }
        }
        Hash(accum, symbols=false)
      }
      _ => Null
    }
  } else if key == "font_fallbacks" {
    data[key] = match val {
      Array(items) =>
        Array(items.map(name => expand_vars(name.to_ruby_s(), data)))
      _ => Array([])
    }
  } else if key.has_prefix("admonition_icon_") {
    if val.truthy() {
      let accum : Map[String, Value] = Map([])
      match val {
        Hash(map, ..) =>
          for key2, val2 in map {
            let key2 = tr_dash(key2)
            accum[key2] = if key2.has_suffix("_color") {
              to_color(evaluate(val2, data, math=false))
            } else {
              evaluate(val2, data)
            }
          }
        _ => ()
      }
      data[key] = Hash(accum, symbols=true)
    }
  } else if val is Hash(map, ..) {
    let key = match deprecated_category(key) {
      Some(rekey) => {
        warnings.push(
          "WARN: the \{key.replace_all(old="_", new="-")} theme category is deprecated; use the \{rekey.replace_all(old="_", new="-")} category instead",
        )
        rekey
      }
      None => key
    }
    for subkey, subval in map {
      let sub = if key == "role" { subkey } else { tr_dash(subkey) }
      process_entry("\{key}_\{sub}", subval, data)
    }
  } else if deprecated_key(key) is Some(rekey) {
    data[rekey] = evaluate(val, data, math=false)
  } else if key.has_prefix("role_") && key.has_suffix("_align") {
    // RoleAlignKeyRx: (?:_text)?_align$
    let base = if key.has_suffix("_text_align") {
      key[:key.length() - 11].to_owned()
    } else {
      key[:key.length() - 6].to_owned()
    }
    data[base + "_text_align"] = evaluate(val, data, math=false)
  } else if padding_bottom_hack_keys.contains(key) {
    let val = evaluate(val, data)
    match val {
      Array(items) if items.length() > 0 &&
        items[0].to_f() >= 0.0 &&
        items.get(2).unwrap_or(Null).to_f() <= 0.0 => {
        let items = items.copy()
        while items.length() < 3 {
          items.push(Null)
        }
        items[2] = items[0]
        data[key] = Array(items)
      }
      _ => data[key] = val
    }
  } else if key.has_suffix("_color") {
    match val {
      Array(items) if key == "table_border_color" ||
        (key == "table_grid_color" && items.length() == 2) =>
        data[key] = Array(
          items.map(it => to_color(evaluate(it, data, math=false))),
        )
      _ => data[key] = to_color(evaluate(val, data, math=false))
    }
  } else if key.has_suffix("_content") {
    data[key] = Str(expand_vars(val.to_ruby_s(), data).to_ruby_s())
  } else {
    data[key] = evaluate(val, data)
  }
}

///|
/// `PaddingBottomHackKeys`
let padding_bottom_hack_keys : Array[String] = [
  "example_padding", "quote_padding", "sidebar_padding", "verse_padding",
]

///|
/// `ThemeLoader#evaluate`
fn evaluate(expr : Value, vars : Theme, math? : Bool = true) -> Value {
  match expr {
    Str(s) => {
      let expanded = expand_vars(s, vars)
      if math {
        evaluate_math(expanded)
      } else {
        expanded
      }
    }
    Array(items) => Array(items.map(e => evaluate(e, vars, math~)))
    _ => expr
  }
}

///|
/// `[a-z0-9_-]`
fn is_var_char(c : Char) -> Bool {
  (c >= 'a' && c <= 'z') || is_digit(c) || c == '_' || c == '-'
}

///|
/// `LoneVariableRx = /^\$([a-z0-9_-]+)$/`: the variable's name.
fn lone_variable(expr : String) -> String? {
  if expr.length() < 2 || expr[0] != '$' {
    return None
  }
  let name = expr[1:].to_owned()
  if name.iter().all(is_var_char) {
    Some(name)
  } else {
    None
  }
}

///|
/// `ThemeLoader#expand_vars`: a lone `$variable` (or `-$variable`) is
/// replaced by the variable's value itself (a number stays a number);
/// otherwise each `$variable` is replaced by its value's `to_s`.
fn expand_vars(expr : String, vars : Theme) -> Value {
  guard expr.find("$") is Some(idx) else { return Str(expr) }
  if idx == 0 {
    match lone_variable(expr) {
      Some(name) => return resolve_var(vars, expr, name)
      None => ()
    }
  } else if idx == 1 && expr[0] == '-' {
    let negated = expr[1:].to_owned()
    match lone_variable(negated) {
      Some(name) =>
        return match resolve_var(vars, negated, name) {
          Int(i) => Int(-i)
          Float(f) => Float(-f)
          other => Str("-" + other.to_ruby_s())
        }
      None => ()
    }
  }
  // gsub(VariableRx) { resolve_var vars, $&, $1 }
  let sb = StringBuilder()
  let chars : Array[Char] = expr.iter().collect()
  let mut i = 0
  while i < chars.length() {
    if chars[i] == '$' && i + 1 < chars.length() && is_var_char(chars[i + 1]) {
      let mut j = i + 1
      while j < chars.length() && is_var_char(chars[j]) {
        j += 1
      }
      let name = String::from_array(chars[i + 1:j])
      let written = "$" + name
      sb.write_string(resolve_var(vars, written, name).to_ruby_s())
      i = j
    } else {
      sb.write_char(chars[i])
      i += 1
    }
  }
  Str(sb.to_string())
}

///|
/// `ThemeLoader#resolve_var`: the value of the key the variable names
/// (dashes as underscores; a deprecated category or key resolves to its
/// replacement); an unknown variable stays as written.
fn resolve_var(vars : Theme, written : String, name : String) -> Value {
  let name = tr_dash(name)
  if vars.has(name) {
    return vars[name]
  }
  for entry in deprecated_category_keys {
    let (old_key, new_key) = entry
    if name.has_prefix(old_key + "_") {
      let replace = new_key + name[old_key.length():].to_owned()
      if vars.has(replace) {
        return vars[replace]
      }
    }
  }
  match deprecated_key(name) {
    Some(replace) if vars.has(replace) => return vars[replace]
    _ => ()
  }
  warnings.push("WARN: unknown variable reference in PDF theme: \{written}")
  Str(written)
}

///|
/// A number `-?\d+(?:\.\d+)?` starting at `start`: its end.
fn number_after(chars : Array[Char], start : Int) -> Int? {
  let mut i = start
  if i < chars.length() && chars[i] == '-' {
    i += 1
  }
  let digits = i
  while i < chars.length() && is_digit(chars[i]) {
    i += 1
  }
  if i == digits {
    return None
  }
  if i + 1 < chars.length() && chars[i] == '.' && is_digit(chars[i + 1]) {
    i += 1
    while i < chars.length() && is_digit(chars[i]) {
      i += 1
    }
  }
  Some(i)
}

///|
/// One `gsub` pass of `(-?\d+(?:\.\d+)?) +([ops]) +(-?\d+(?:\.\d+)?)`:
/// scan left to right for the leftmost match, replace it with the result
/// (`Float#to_s`), continue after it.
fn apply_ops(expr : String, ops : String) -> String {
  let chars : Array[Char] = expr.iter().collect()
  let n = chars.length()
  let sb = StringBuilder()
  let mut pos = 0
  let mut start = 0
  while start < n {
    // the leftmost match beginning at `start`
    match match_op_at(chars, start, ops) {
      Some((end, left, op, right)) => {
        sb.write_string(String::from_array(chars[pos:start]))
        let a = ruby_string_to_f(left)
        let b = ruby_string_to_f(right)
        let result = match op {
          '*' => a * b
          '/' => a / b
          '^' => @math.pow(a, b)
          '+' => a + b
          _ => a - b
        }
        sb.write_string(ruby_float_to_s(result))
        pos = end
        start = end
      }
      None => start += 1
    }
  }
  sb.write_string(String::from_array(chars[pos:]))
  sb.to_string()
}

///|
/// The operation `(-?\d+(?:\.\d+)?) +([ops]) +(-?\d+(?:\.\d+)?)` matched at
/// `start`, as Onigmo backtracks: the left number greedy (then shorter),
/// one or more spaces, the operator, spaces, the right number greedy.
fn match_op_at(
  chars : Array[Char],
  start : Int,
  ops : String,
) -> (Int, String, Char, String)? {
  let n = chars.length()
  let mut i = start
  if i < n && chars[i] == '-' {
    i += 1
  }
  let digits = i
  while i < n && is_digit(chars[i]) {
    i += 1
  }
  if i == digits {
    return None
  }
  // candidate ends of the left number: with the fraction, then without
  let ends : Array[Int] = []
  if i + 1 < n && chars[i] == '.' && is_digit(chars[i + 1]) {
    let mut j = i + 1
    while j < n && is_digit(chars[j]) {
      j += 1
    }
    ends.push(j)
  }
  ends.push(i)
  for left_end in ends {
    let mut k = left_end
    if k >= n || chars[k] != ' ' {
      continue
    }
    while k < n && chars[k] == ' ' {
      k += 1
    }
    if k >= n || !ops.contains_char(chars[k]) {
      continue
    }
    let op = chars[k]
    k += 1
    if k >= n || chars[k] != ' ' {
      continue
    }
    while k < n && chars[k] == ' ' {
      k += 1
    }
    match number_after(chars, k) {
      Some(right_end) =>
        return Some(
          (
            right_end,
            String::from_array(chars[start:left_end]),
            op,
            String::from_array(chars[k:right_end]),
          ),
        )
      None => ()
    }
  }
  None
}

///|
fn count_chars(s : String, set : String) -> Int {
  let mut n = 0
  for c in s {
    if set.contains_char(c) {
      n += 1
    }
  }
  n
}

///|
/// `ThemeLoader#evaluate_math`: measurements to points, then `*`, `/`,
/// `^`, then `+`, `-` (each repeatedly, left to right, operands and spaced
/// operators only), then an outer `round(...)`, `floor(...)` or
/// `ceil(...)`; a changed expression becomes a number (an Integer when it
/// is whole).
fn evaluate_math(value : Value) -> Value {
  guard value is Str(original) else { return value }
  let mut expr = resolve_measurement_values(original)
  while count_chars(expr, "*/^") > 0 {
    let result = apply_ops(expr, "*/^")
    let unchanged = result == expr
    expr = result
    if unchanged {
      break
    }
  }
  while count_chars(expr, "+-") > 0 {
    let result = apply_ops(expr, "+-")
    let unchanged = result == expr
    expr = result
    if unchanged {
      break
    }
  }
  // PrecisionFuncRx = /^(round|floor|ceil)\(/
  if expr.has_suffix(")") {
    for op in ["round", "floor", "ceil"] {
      if expr.has_prefix(op + "(") {
        let inner = ruby_string_to_f(
          expr[op.length() + 1:expr.length() - 1].to_owned(),
        )
        let rounded = match op {
          // Float#round: half away from zero
          "round" => {
            let a = inner.abs()
            let r = if a - a.floor() >= 0.5 {
              a.floor() + 1.0
            } else {
              a.floor()
            }
            if inner < 0.0 {
              -r
            } else {
              r
            }
          }
          "floor" => inner.floor()
          _ => inner.ceil()
        }
        return Int(float_to_i(rounded))
      }
    }
  }
  if expr == original {
    return value
  }
  let int_val = ruby_string_to_i(expr)
  let flt_val = ruby_string_to_f(expr)
  if int_val.to_double() == flt_val {
    Int(int_val)
  } else {
    Float(flt_val)
  }
}

///|
/// `ThemeLoader#to_color`
pub fn to_color(value : Value) -> Value {
  let value = match value {
    Hex(_) | Transparent | Cmyk(_) => return value
    Array(items) =>
      match items.length() {
        4 => {
          let cmyk = items.map(e => {
            let f = match e {
              Int(i) =>
                if i > 1L {
                  i.to_double()
                } else {
                  i.to_double() * 100.0
                }
              Float(f) => if f > 1.0 { f } else { f * 100.0 }
              other => {
                let s = other.to_ruby_s()
                ruby_string_to_f(
                  if s.has_suffix("%") {
                    s[:s.length() - 1].to_owned()
                  } else {
                    s
                  },
                )
              }
            }
            if f == float_to_i(f).to_double() {
              Int(float_to_i(f))
            } else {
              Float(f)
            }
          })
          let ints = cmyk.map(v => v.to_f())
          if ints.iter().all(x => x == 0.0) {
            return Hex("FFFFFF")
          }
          if ints.iter().all(x => x == 100.0) {
            return Hex("000000")
          }
          return Cmyk(cmyk)
        }
        3 => {
          let sb = StringBuilder()
          for e in items {
            let n = match e {
              Int(i) => i
              Float(f) => float_to_i(f.floor())
              other => ruby_string_to_i(other.to_ruby_s())
            }
            let hex = if n < 0L {
              ".." + (256L + n).to_string(radix=16).to_upper()
            } else {
              n.to_string(radix=16).to_upper()
            }
            if hex.length() < 2 {
              sb.write_char('0')
            }
            sb.write_string(hex)
          }
          return Hex(sb.to_string())
        }
        // a nonsense array: flattened to a string
        _ => items.map(e => e.to_ruby_s()).join("")
      }
    Str(s) =>
      if s == "transparent" {
        return Transparent
      } else if s.length() == 6 {
        return Hex(s.to_upper())
      } else {
        s
      }
    Null => return Null
    // an Integer (usually), coerced to a string
    other => {
      let s = other.to_ruby_s()
      if s.length() == 6 {
        return Hex(s.to_upper())
      }
      s
    }
  }
  let resolved = match value.length() {
    6 => value
    // expand hex shorthand (f00 -> ff0000)
    3 => {
      let sb = StringBuilder()
      for c in value {
        sb.write_char(c)
        sb.write_char(c)
      }
      sb.to_string()
    }
    // truncate or pad with leading zeros (ff -> 0000ff)
    _ => {
      let v = if value.length() > 6 { value[:6].to_owned() } else { value }
      let sb = StringBuilder()
      for _ in 0..<(6 - v.length()) {
        sb.write_char('0')
      }
      sb.write_string(v)
      sb.to_string()
    }
  }
  Hex(resolved.to_upper())
}