# # Created by Boyd Multerer on 24/02/19. # Copyright © 2019 Kry10 Industries. All rights reserved. # defmodule FontMetrics do @moduledoc """ FontMetrics works with pre-generated font metrics to explore and calculate various measurements of text in a given font and size. For example, if you want to know how wide or tall a string of text will be when it is rendered in a given font at a given size, then this can help you out. This library is intended to be used with the [Scenic](https://hex.pm/packages/scenic) framework, but doesn't depend on it, so it is usable elsewhere. ## Installation If [available in Hex](https://hex.pm/docs/publish), the package can be installed by adding `font_metrics` to your list of dependencies in `mix.exs`: ```elixir def deps do [ {:font_metrics, "~> 0.3"} ] end ``` ## Generating Metrics You will need to use another package to compile the font metrics data from a font. This can be done with the truetype_metrics package. Look for it on hex... """ # This represents the version of the data format, not the hex package. # Hopefully, it doesn't change much if at all... @version "0.1.0" @name to_string(__MODULE__) @signature_type :sha256 @signature_name to_string(@signature_type) @point_to_pixel_ratio 4 / 3 # import IEx # =========================================================================== @derive [{Msgpax.Packer, include_struct_field: true}] defstruct version: nil, source: nil, direction: nil, smallest_ppem: nil, units_per_em: nil, max_box: nil, ascent: nil, descent: nil, metrics: %{}, kerning: %{} # =========================================================================== defmodule Error do @moduledoc false defexception message: "", error: nil, data: nil end # ============================================================================ # high-level functions # -------------------------------------------------------- @doc """ The type of hash used to verify the signature This should return `:sha256` """ def expected_hash(), do: @signature_type # -------------------------------------------------------- @doc """ Serialize a `%FontMetrics{}` struct to a binary. returns `{:ok, binary}` """ def to_binary(%{version: @version} = metrics) do metrics |> prep_bin() |> Msgpax.pack() |> case do {:ok, io_list} -> {:ok, :zlib.zip(io_list)} err -> err end end # -------------------------------------------------------- @doc """ Serialize a `%FontMetrics{}` struct to a binary. returns `binary` """ def to_binary!(%{version: @version} = metrics) do metrics |> prep_bin() |> Msgpax.pack!() |> :zlib.zip() end defp prep_bin( %{ max_box: {x_min, y_min, x_max, y_max}, kerning: kerning, source: %{font_type: font_type} = source } = metrics ) do font_type = case font_type do :true_type -> "TrueType" end source = Map.put(source, :font_type, font_type) metrics |> Map.put(:max_box, [x_min, y_min, x_max, y_max]) |> Map.put(:kerning, Enum.map(kerning, fn {{a, b}, v} -> [a, b, v] end)) |> Map.put(:source, source) end # -------------------------------------------------------- @doc """ Deserialize a binary into a `%FontMetrics{}`. returns `{:ok, font_metric}` """ def from_binary(binary) when is_binary(binary) do with {:ok, bin} <- do_unzip(binary), {:ok, map} <- Msgpax.unpack(bin) do intrepret_unpack(map) else err -> err end end defp do_unzip(binary) do try do {:ok, :zlib.unzip(binary)} rescue _ -> {:error, :unzip} end end # -------------------------------------------------------- @doc """ Deserialize a binary into a `%FontMetrics{}`. returns `font_metric` """ def from_binary!(binary) when is_binary(binary) do :zlib.unzip(binary) |> Msgpax.unpack!() |> intrepret_unpack!() end # ------------------------------------ defp intrepret_unpack(%{ "__struct__" => @name, "version" => @version, "direction" => direction, "ascent" => ascent, "descent" => descent, "smallest_ppem" => smallest_ppem, "units_per_em" => units_per_em, "max_box" => [x_min, y_min, x_max, y_max], "kerning" => kerning, "metrics" => metrics, "source" => %{ "created_at" => created_at, "modified_at" => modified_at, "font_type" => font_type, "signature" => signature, "signature_type" => @signature_name, "file" => file } }) do font_type = case font_type do "TrueType" -> :true_type end {:ok, %FontMetrics{ version: @version, direction: direction, ascent: ascent, descent: descent, smallest_ppem: smallest_ppem, units_per_em: units_per_em, max_box: {x_min, y_min, x_max, y_max}, kerning: Enum.map(kerning, fn [a, b, v] -> {{a, b}, v} end) |> Enum.into(%{}), metrics: metrics, source: %FontMetrics.Source{ created_at: created_at, modified_at: modified_at, font_type: font_type, signature: signature, signature_type: :sha256, file: file } }} end defp intrepret_unpack(%{"version" => version}) when version != @version do {:error, :version, version} end defp intrepret_unpack(%{"signature_type" => sig}) when sig != @signature_name do {:error, :signature_type, sig} end defp intrepret_unpack(_), do: {:error, :invalid} defp intrepret_unpack!(map) do case intrepret_unpack(map) do {:ok, font_metrics} -> font_metrics err -> raise %FontMetrics.Error{message: "Invalid metrics", error: err, data: map} end end # ============================================================================ # validity checks # -------------------------------------------------------- @doc """ Checks if all the characters can be rendered by the font returns `true` or `false` """ def supported?(codepoint, %FontMetrics{metrics: metrics, version: @version}) when is_integer(codepoint) do Map.has_key?(metrics, codepoint) end def supported?(char_list, %FontMetrics{} = metrics) when is_list(char_list) do Enum.all?(char_list, &supported?(&1, metrics)) end def supported?(string, %FontMetrics{} = metrics) when is_bitstring(string) do string |> String.to_charlist() |> supported?(metrics) end # ============================================================================ # use # -------------------------------------------------------- @doc """ Get the ascent of the font scaled to the pixel height returns `ascent` """ def ascent(pixels, font_metrics) def ascent(nil, %FontMetrics{ascent: ascent, version: @version}), do: ascent def ascent( pixels, %FontMetrics{ascent: ascent, descent: descent, version: @version} ) do ascent * (pixels / (ascent - descent)) end # -------------------------------------------------------- @doc """ Get the descent of the font scaled to the pixel height returns `descent` """ def descent(pixels, font_metrics) def descent(nil, %FontMetrics{descent: descent, version: @version}), do: descent def descent( pixels, %FontMetrics{ascent: ascent, descent: descent, version: @version} ) do descent * (pixels / (ascent - descent)) end # -------------------------------------------------------- @doc """ Transform point values into pixels returns `pixels` """ def points_to_pixels(points) when is_number(points), do: points * @point_to_pixel_ratio # -------------------------------------------------------- @doc """ Return a box that would hold the largest character in the font. The response is scaled to the pixel size. returns `{x_min, y_min, x_max, y_max}` """ def max_box(pixels, font_metrics) def max_box(nil, %FontMetrics{max_box: max_box, version: @version}), do: max_box def max_box( pixels, %FontMetrics{ max_box: {x_min, y_min, x_max, y_max}, ascent: ascent, descent: descent, version: @version } ) do scale = pixels / (ascent - descent) {x_min * scale, y_min * scale, x_max * scale, y_max * scale} end # -------------------------------------------------------- @doc """ Measure the width of a string, scaled to a pixel size returns `width` """ def width(source, pixels, font_metrics, kern \\ false) def width("", _, _, _), do: 0 def width('', _, _, _), do: 0 def width( source, nil, %FontMetrics{ metrics: cp_metrics, kerning: kerning, version: @version }, kern ) do do_width(source, 1.0, cp_metrics, kerning, kern) end def width( source, pixels, %FontMetrics{ metrics: cp_metrics, ascent: ascent, descent: descent, kerning: kerning, version: @version }, kern ) when is_number(pixels) and pixels > 0 do scale = pixels / (ascent - descent) do_width(source, scale, cp_metrics, kerning, kern) end defp do_width(codepoint, scale, cp_metrics, _, _) when is_integer(codepoint) do # if the codepoint isn't supported by the font, use 0 - missing character tofu (cp_metrics[codepoint] || cp_metrics[0]) * scale end # no kerning. easy. defp do_width(codepoints, scale, cp_metrics, _, false) when is_list(codepoints) do Enum.reduce(codepoints, 0, fn codepoint, width -> # if the codepoint isn't supported by the font, use 0 - missing character tofu width + (cp_metrics[codepoint] || cp_metrics[0]) end) * scale end # yes. kerning. harder. defp do_width(codepoints, scale, cp_metrics, kerning, true) when is_list(codepoints) do do_kerned_width(codepoints, scale, cp_metrics, kerning) end # if the string has multiple lines, return the width of the longest one defp do_width(string, scale, cp_metrics, kerning, kern) when is_bitstring(string) do string |> String.split("\n") |> Enum.map(&String.to_charlist(&1)) |> Enum.reduce(0, fn line, max_width -> case do_width(line, scale, cp_metrics, kerning, kern) do width when width > max_width -> width _ -> max_width end end) end defp do_kerned_width(codepoints, scale, cp_metrics, kerning, width \\ 0) defp do_kerned_width([], scale, _, _, width), do: width * scale defp do_kerned_width([last_cp], scale, cp_metrics, _, width) do adv = cp_metrics[last_cp] || cp_metrics[0] (width + adv) * scale end defp do_kerned_width([cp | codepoints], scale, cp_metrics, kerning, width) do [next_cp | _] = codepoints kern_amount = Map.get(kerning, {cp, next_cp}, 0) width = width + (cp_metrics[cp] || cp_metrics[0]) + kern_amount do_kerned_width(codepoints, scale, cp_metrics, kerning, width) end # -------------------------------------------------------- @doc """ Shorten a string to fit a given width options * :kern - account for Kerning - true or false * :terminator - add this string to the end of the shortened string. Defaults to "..." returns `string` """ def shorten(source, max_width, pixels, font_metrics, opts \\ []) def shorten( source, max_width, pixels, %FontMetrics{ metrics: cp_metrics, ascent: ascent, descent: descent, kerning: kerning, version: @version } = font_metrics, opts ) when is_list(source) and is_list(opts) do kern = !!opts[:kern] terminator = case opts[:terminator] do nil -> '...' t when is_list(t) -> t t when is_bitstring(t) -> String.to_charlist(t) end # calculate the scale to use scale = case pixels do nil -> 1.0 p -> p / (ascent - descent) end # terminator_width = do_width( terminator, scale, font_metrics, kern ) terminator_width = do_width(terminator, scale, cp_metrics, kerning, kern) do_shorten( source, max_width - terminator_width, scale, font_metrics, kern ) |> case do '' -> '' out -> case Enum.reverse(out) do ^source -> source out -> out ++ terminator end end end def shorten(source, width, max_pixels, %FontMetrics{} = font_metrics, opts) when is_bitstring(source) do source |> String.split("\n") |> Enum.map(&String.to_charlist(&1)) |> Enum.map(&shorten(&1, width, max_pixels, font_metrics, opts)) |> Enum.map(&to_string(&1)) |> Enum.join("\n") end def do_shorten(_, max_width, _, _, _) when max_width <= 0, do: '' # various ways to structure the code. This attempts to reuse calculations and # and keep it to a single pass as much as possible # no kerning def do_shorten( source, max_width, scale, %FontMetrics{metrics: cp_metrics}, false ) do max_width = max_width / scale {out, _} = Enum.reduce_while(source, {[], 0}, fn cp, {acc, width} -> new_width = width + (cp_metrics[cp] || cp_metrics[0]) cond do new_width < max_width -> {:cont, {[cp | acc], new_width}} true -> {:halt, {acc, width}} end end) out end # yes kerning def do_shorten( source, max_width, scale, %FontMetrics{metrics: cp_metrics, kerning: kerning}, true ) do max = max_width / scale do_kern_shorten(source, max, cp_metrics, kerning) end defp do_kern_shorten(codepoints, max, cp_metrics, kerning, k_next \\ 0, width \\ 0, out \\ '') defp do_kern_shorten('', _, _, _, _, _, out), do: out defp do_kern_shorten([last_cp], max, cp_metrics, _, k_next, width, out) do adv = cp_metrics[last_cp] || cp_metrics[0] cond do adv + width + k_next <= max -> [last_cp | out] true -> out end end defp do_kern_shorten([cp | codepoints], max, cp_metrics, kerning, k_next, w_in, out) do width = w_in + (cp_metrics[cp] || cp_metrics[0]) + k_next # prep the next k_next [cp_next | _] = codepoints k_next = Map.get(kerning, {cp, cp_next}, 0) cond do width <= max -> do_kern_shorten(codepoints, max, cp_metrics, kerning, k_next, width, [cp | out]) true -> out end end # -------------------------------------------------------- @doc """ Find the gap between to characters given an {x,y} coordinate options * :kern - account for Kerning - true or false returns `{character_number, x_position, line_number}` """ def nearest_gap(source, pos, pixels, font_metrics, opts \\ []) def nearest_gap(_, {_, y}, _, _, _) when y < 0, do: {0, 0, 0} def nearest_gap( line, {x, _}, pixels, %FontMetrics{ metrics: cp_metrics, ascent: ascent, descent: descent, kerning: kerning, version: @version }, opts ) when is_list(line) and is_list(opts) do kern = !!opts[:kern] # calculate the scaled x and y to use scale = case pixels do nil -> 1.0 p -> p / (ascent - descent) end x = x / scale do_nearest_gap(line, x, cp_metrics, kerning, kern) end def nearest_gap( source, {x, y}, pixels, %FontMetrics{ metrics: cp_metrics, ascent: ascent, descent: descent, kerning: kerning, version: @version } = fm, opts ) when is_bitstring(source) do # calculate the scale factor scale = case pixels do nil -> 1.0 p -> p / (ascent - descent) end # calculate what line we are interested in line_height = opts[:line_height] || pixels line_no = trunc(y / line_height) lines = String.split(source, "\n") line = Enum.at(lines, line_no) case line do nil -> line_no = case Enum.count(lines) do 0 -> 0 c -> c - 1 end {n, w} = case List.last(lines) do nil -> {0, 0} line -> { String.length(line), width(line, pixels, fm, opts) * scale } end # past the last line - put it at the end {n, w, line_no} line -> kern = !!opts[:kern] x = x / scale line = String.to_charlist(line) {n, w} = do_nearest_gap(line, x, cp_metrics, kerning, kern) {n, w * scale, line_no} end end # by this point, it should only be one line defp do_nearest_gap(line, x, cp_metrics, kerning, kern, k_next \\ 0, width \\ 0, n \\ 0) defp do_nearest_gap(_, x, _, _, _, _, _, _) when x <= 0, do: {0, 0} defp do_nearest_gap('', _, _, _, _, _, w, n), do: {n, w} # non-kerned gap finder defp do_nearest_gap([cp | cps], x, cp_metrics, kerning, kern, k_next, width, n) do adv = cp_metrics[cp] || cp_metrics[0] new_width = width + adv + k_next case new_width > x do false -> # calc the next kerning amount k_next = case kern do false -> 0 true -> case cps do [] -> 0 [cpn | _] -> kerning[{cp, cpn}] || 0 end end # recurse to the next character do_nearest_gap(cps, x, cp_metrics, kerning, kern, k_next, new_width, n + 1) true -> # we are past the test point. Now figure out where the halfway point is w_half = width + (adv + k_next) / 2 cond do w_half < x -> {n + 1, new_width} true -> {n, width} end end end # -------------------------------------------------------- @doc """ Returns the coordinates just before the given character number. options * :kern - account for Kerning - true or false returns `{x_position, line_number}` """ def position_at(source, n, pixels, font_metric, opts \\ []) def position_at( source, n, pixels, %FontMetrics{ metrics: cp_metrics, ascent: ascent, descent: descent, kerning: kerning, version: @version }, opts ) when is_list(source) do kern = !!opts[:kern] # calculate the scale factor scale = case pixels do nil -> 1.0 p -> p / (ascent - descent) end {x, l} = do_position_at(source, n, cp_metrics, kerning, kern) {x * scale, l} end def position_at(source, n, pixels, fm, opts) when is_bitstring(source) do String.to_charlist(source) |> position_at(n, pixels, fm, opts) end defp do_position_at(line, n, cp_metrics, kerning, kern, k_next \\ 0, line_no \\ 0, width \\ 0) defp do_position_at('', _, _, _, _, _, line_no, width), do: {width, line_no} defp do_position_at(_, -1, _, _, _, _, line_no, width), do: {width, line_no} # handle newlines defp do_position_at([10 | cps], n, cp_metrics, kerning, kern, _, line_no, _) do do_position_at(cps, n - 1, cp_metrics, kerning, kern, 0, line_no + 1, 0) end defp do_position_at([cp | cps], n, cp_metrics, kerning, kern, k_next, line_no, width) do adv = cp_metrics[cp] || cp_metrics[0] width = width + adv + k_next # calc the next kerning amount k_next = case kern do false -> 0 true -> case cps do [] -> 0 [cpn | _] -> kerning[{cp, cpn}] || 0 end end do_position_at(cps, n - 1, cp_metrics, kerning, kern, k_next, line_no, width) end end