defmodule NativeElixirPdfUtilities.HtmlToPdf.Font do @moduledoc """ Font loading, fallback resolution, text measurement, and PDF text encoding. The renderer loads explicitly configured fonts and also discovers a small set of common system sans-serif fonts when they are available. """ @type font_style :: :normal | :italic @type registry :: %{embedded: [embedded_font()]} @type built_in_font :: %{type: :built_in, family: String.t(), pdf_name: String.t()} @type embedded_font :: %{ type: :embedded, family: String.t(), weight: number(), style: font_style(), id: String.t(), pdf_name: String.t(), data: binary(), units_per_em: pos_integer(), widths: [non_neg_integer()], default_width: non_neg_integer(), cmap: %{optional(non_neg_integer()) => non_neg_integer()}, ascent: integer(), descent: integer(), bbox: {integer(), integer(), integer(), integer()} } @type font_face :: built_in_font() | embedded_font() @built_in_families ["Courier", "Helvetica", "Times-Roman"] @system_font_candidates [ %{ family: "Arial", path: "/usr/share/fonts/truetype/msttcorefonts/Arial.ttf", weight: 400, style: :normal }, %{ family: "Arial", path: "/usr/share/fonts/truetype/msttcorefonts/Arial_Bold.ttf", weight: 700, style: :normal }, %{ family: "Arial", path: "/usr/share/fonts/truetype/msttcorefonts/Arial_Italic.ttf", weight: 400, style: :italic }, %{ family: "Arial", path: "/usr/share/fonts/truetype/msttcorefonts/Arial_Bold_Italic.ttf", weight: 700, style: :italic }, %{ family: "Liberation Sans", path: "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", weight: 400, style: :normal }, %{ family: "Liberation Sans", path: "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", weight: 700, style: :normal }, %{ family: "Liberation Sans", path: "/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf", weight: 400, style: :italic }, %{ family: "Liberation Sans", path: "/usr/share/fonts/truetype/liberation/LiberationSans-BoldItalic.ttf", weight: 700, style: :italic }, %{ family: "DejaVu Sans", path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", weight: 400, style: :normal }, %{ family: "DejaVu Sans", path: "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", weight: 700, style: :normal }, %{ family: "DejaVu Sans", path: "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf", weight: 400, style: :italic }, %{ family: "DejaVu Sans", path: "/usr/share/fonts/truetype/dejavu/DejaVuSans-BoldOblique.ttf", weight: 700, style: :italic }, %{ family: "Noto Sans", path: "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", weight: 400, style: :normal }, %{ family: "Noto Sans", path: "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", weight: 700, style: :normal } ] @doc """ Loads explicit TTF font options into a registry. Accepted font entries are maps, keyword lists, or `{family, path}` tuples. Map and keyword entries must include `:family` and `:path`; `:weight` and `:style` are optional. """ @spec load_registry(keyword()) :: {:ok, registry()} | :error def load_registry(opts) do case Keyword.keyword?(opts) do true -> case Keyword.get(opts, :fonts, []) do fonts when is_list(fonts) -> Enum.reduce_while(fonts, {:ok, []}, fn font, {:ok, acc} -> case load_font(font) do {:ok, loaded} -> {:cont, {:ok, [loaded | acc]}} :error -> {:halt, :error} end end) |> case do {:ok, embedded} -> embedded = Enum.reverse(embedded) {:ok, %{embedded: embedded ++ system_fonts(embedded)}} :error -> :error end _ -> :error end false -> :error end end @doc """ Resolves a CSS font-family value or fallback list to a supported font face. """ @spec resolve(String.t() | [String.t()], number(), font_style(), registry()) :: {:ok, [String.t()], font_face()} | :error def resolve(family_value, weight, style, registry) do families = font_families(family_value) case Enum.find_value(families, &resolve_family(&1, weight, style, registry)) do nil -> :error font -> {:ok, families, font} end end @doc """ Returns the PDF resource key for a selected font face. """ @spec pdf_name(font_face()) :: String.t() def pdf_name(font) do case font do %{type: :built_in, pdf_name: pdf_name} -> pdf_name %{type: :embedded, id: id} -> "Embedded-" <> id end end @doc """ Measures text in PDF points for the selected font and size. """ @spec text_width(String.t(), map(), number()) :: number() def text_width(text, font, font_size) do case font do %{type: :embedded, units_per_em: units_per_em} -> text |> String.to_charlist() |> Enum.reduce(0, fn codepoint, acc -> glyph_id = Map.get(font.cmap, codepoint, 0) acc + glyph_width(font, glyph_id) end) |> Kernel./(units_per_em) |> Kernel.*(font_size) _ -> text |> String.length() |> Kernel.*(font_size) |> Kernel.*(0.6) end end @doc """ Encodes text for an embedded Type0 font content stream. """ @spec encode_embedded_text(String.t(), embedded_font()) :: String.t() def encode_embedded_text(text, font) do text |> String.to_charlist() |> Enum.map_join("", fn codepoint -> glyph_id = Map.get(font.cmap, codepoint, 0) <> |> Base.encode16(case: :upper) end) end @doc """ Builds CID-to-Unicode mappings for all text shown with an embedded font. """ @spec unicode_mappings([String.t()], embedded_font()) :: %{ optional(non_neg_integer()) => non_neg_integer() } def unicode_mappings(texts, font) do Enum.reduce(texts, %{}, fn text, acc -> text |> String.to_charlist() |> Enum.reduce(acc, fn codepoint, mappings -> glyph_id = Map.get(font.cmap, codepoint, 0) case glyph_id do 0 -> mappings glyph_id -> Map.put_new(mappings, glyph_id, codepoint) end end) end) end defp load_font(font) do with {:ok, family, path, weight, style} <- font_config(font), {:ok, data} <- File.read(path), {:ok, parsed} <- parse_ttf(data) do hash = :crypto.hash(:sha256, [family, data]) |> Base.encode16(case: :lower) |> binary_part(0, 12) {:ok, parsed |> Map.merge(%{ type: :embedded, family: family, weight: weight, style: style, id: hash, pdf_name: pdf_safe_name(family) <> "-" <> hash, data: data })} else _ -> :error end end defp system_fonts(explicit_fonts) do explicit_keys = explicit_fonts |> Enum.map(&{String.downcase(&1.family), &1.weight, &1.style}) |> MapSet.new() system_font_candidates() |> Enum.reject(fn font -> MapSet.member?(explicit_keys, {String.downcase(font.family), font.weight, font.style}) or not File.regular?(font.path) end) |> Enum.uniq_by(&{String.downcase(&1.family), &1.weight, &1.style}) |> Enum.reduce([], fn font, acc -> loaded = load_font(font) if match?({:ok, _loaded}, loaded), do: acc ++ [elem(loaded, 1)], else: acc end) end defp system_font_candidates do @system_font_candidates ++ user_arial_font_candidates() end defp user_arial_font_candidates do with {:ok, home} <- System.fetch_env("HOME") do [ %{ family: "Arial", path: Path.join(home, ".local/share/fonts/Monotype/TrueType/Arial/Arial_Regular.ttf"), weight: 400, style: :normal }, %{ family: "Arial", path: Path.join(home, ".local/share/fonts/Monotype/TrueType/Arial/Arial_Bold.ttf"), weight: 700, style: :normal }, %{ family: "Arial", path: Path.join(home, ".local/share/fonts/Monotype/TrueType/Arial/Arial_Italic.ttf"), weight: 400, style: :italic }, %{ family: "Arial", path: Path.join(home, ".local/share/fonts/Monotype/TrueType/Arial/Arial_Bold_Italic.ttf"), weight: 700, style: :italic } ] else _ -> [] end end defp font_config(font) do case font do {family, path} when is_binary(family) and is_binary(path) -> {:ok, family, path, 400, :normal} font when is_list(font) -> case Keyword.keyword?(font) do true -> font_config(Map.new(font)) false -> :error end font when is_map(font) -> family = Map.get(font, :family) || Map.get(font, "family") path = Map.get(font, :path) || Map.get(font, "path") weight = Map.get(font, :weight) || Map.get(font, "weight") || 400 style = Map.get(font, :style) || Map.get(font, "style") || :normal with true <- is_binary(family) and String.trim(family) != "", true <- is_binary(path) and String.trim(path) != "", {:ok, weight} <- font_weight(weight), {:ok, style} <- font_style(style) do {:ok, String.trim(family), path, weight, style} end _ -> :error end end defp font_weight(weight) do case weight do weight when is_integer(weight) and weight >= 100 and weight <= 900 -> {:ok, weight} weight when is_float(weight) and weight >= 100 and weight <= 900 -> {:ok, weight} "normal" -> {:ok, 400} "bold" -> {:ok, 700} weight when is_binary(weight) -> weight |> String.trim() |> Integer.parse() |> parsed_weight() _ -> :error end end defp parsed_weight({weight, ""}) when weight >= 100 and weight <= 900, do: {:ok, weight} defp parsed_weight(_parsed), do: :error defp font_style(style) do case style do :normal -> {:ok, :normal} :italic -> {:ok, :italic} "normal" -> {:ok, :normal} "italic" -> {:ok, :italic} _ -> :error end end defp font_families(family_value) do case family_value do families when is_list(families) -> families |> Enum.filter(&is_binary/1) |> Enum.map(&normalize_family/1) |> Enum.reject(&(&1 == "")) family when is_binary(family) -> family |> String.split(",", trim: true) |> Enum.map(&normalize_family/1) |> Enum.reject(&(&1 == "")) _ -> [] end end defp normalize_family(family) do family |> String.trim() |> String.trim("\"") |> String.trim("'") |> generic_family() end defp generic_family(family) do case String.downcase(family) do "sans-serif" -> "Helvetica" "serif" -> "Times-Roman" "monospace" -> "Courier" _ -> family end end defp resolve_family(family, weight, style, registry) do cond do family in @built_in_families -> built_in_font(family, weight, style) true -> embedded_family(family, weight, style, registry) end end defp built_in_font(family, weight, style) do pdf_name = case {family, weight >= 700, style} do {"Helvetica", true, :italic} -> "Helvetica-BoldOblique" {"Helvetica", true, _} -> "Helvetica-Bold" {"Helvetica", false, :italic} -> "Helvetica-Oblique" {"Helvetica", false, _} -> "Helvetica" {"Courier", true, :italic} -> "Courier-BoldOblique" {"Courier", true, _} -> "Courier-Bold" {"Courier", false, :italic} -> "Courier-Oblique" {"Courier", false, _} -> "Courier" {"Times-Roman", true, :italic} -> "Times-BoldItalic" {"Times-Roman", true, _} -> "Times-Bold" {"Times-Roman", false, :italic} -> "Times-Italic" {"Times-Roman", false, _} -> "Times-Roman" end %{type: :built_in, family: family, pdf_name: pdf_name} end defp embedded_family(family, weight, style, registry) do normalized_family = String.downcase(family) registry.embedded |> Enum.filter(&(String.downcase(&1.family) == normalized_family)) |> case do [] -> nil fonts -> Enum.min_by(fonts, fn font -> style_penalty = if font.style == style, do: 0, else: 1_000 abs(font.weight - weight) + style_penalty end) end end defp parse_ttf(data) do with {:ok, tables} <- table_directory(data), {:ok, head} <- table(data, tables, "head"), {:ok, hhea} <- table(data, tables, "hhea"), {:ok, maxp} <- table(data, tables, "maxp"), {:ok, hmtx} <- table(data, tables, "hmtx"), {:ok, cmap} <- table(data, tables, "cmap"), {:ok, units_per_em, bbox} <- parse_head(head), {:ok, ascent, descent, hmetric_count} <- parse_hhea(hhea), {:ok, glyph_count} <- read_u16(maxp, 4), {:ok, widths} <- parse_hmtx(hmtx, glyph_count, hmetric_count), {:ok, cmap} <- parse_cmap(cmap) do {:ok, %{ units_per_em: units_per_em, widths: widths, default_width: List.last(widths) || 600, cmap: cmap, ascent: ascent, descent: descent, bbox: bbox }} end end defp table_directory(data) do with {:ok, scaler_type} <- read_u32(data, 0), true <- scaler_type in [0x0001_0000, 0x7472_7565], {:ok, table_count} <- read_u16(data, 4), true <- table_count > 0, true <- byte_size(data) >= 12 + table_count * 16 do records = 0..(table_count - 1) |> Enum.reduce(%{}, fn index, acc -> offset = 12 + index * 16 tag = binary_part(data, offset, 4) <<_checksum::32, table_offset::32, length::32>> = binary_part(data, offset + 4, 12) Map.put(acc, tag, {table_offset, length}) end) {:ok, records} else _ -> :error end end defp table(data, tables, tag) do case Map.get(tables, tag) do {offset, length} when offset >= 0 and length >= 0 and byte_size(data) >= offset + length -> {:ok, binary_part(data, offset, length)} _ -> :error end end defp parse_head(head) do with {:ok, units_per_em} <- read_u16(head, 18), {:ok, x_min} <- read_i16(head, 36), {:ok, y_min} <- read_i16(head, 38), {:ok, x_max} <- read_i16(head, 40), {:ok, y_max} <- read_i16(head, 42), true <- units_per_em > 0 do {:ok, units_per_em, {x_min, y_min, x_max, y_max}} else _ -> :error end end defp parse_hhea(hhea) do with {:ok, ascent} <- read_i16(hhea, 4), {:ok, descent} <- read_i16(hhea, 6), {:ok, hmetric_count} <- read_u16(hhea, 34), true <- hmetric_count > 0 do {:ok, ascent, descent, hmetric_count} else _ -> :error end end defp parse_hmtx(hmtx, glyph_count, hmetric_count) do cond do glyph_count <= 0 or hmetric_count <= 0 or byte_size(hmtx) < hmetric_count * 4 -> :error true -> metric_widths = 0..(hmetric_count - 1) |> Enum.map(fn index -> {:ok, width} = read_u16(hmtx, index * 4) width end) last_width = List.last(metric_widths) extra_count = max(glyph_count - hmetric_count, 0) {:ok, metric_widths ++ List.duplicate(last_width, extra_count)} end end defp parse_cmap(cmap) do with {:ok, subtable_offsets} <- cmap_subtable_offsets(cmap) do subtable_offsets |> Enum.map(&parse_cmap_subtable(cmap, &1)) |> Enum.find(&match?({:ok, _map}, &1)) |> case do {:ok, map} when map_size(map) > 0 -> {:ok, map} _ -> :error end end end defp cmap_subtable_offsets(cmap) do with {:ok, count} <- read_u16(cmap, 2), true <- count > 0, true <- byte_size(cmap) >= 4 + count * 8 do offsets = 0..(count - 1) |> Enum.map(fn index -> offset = 4 + index * 8 {:ok, platform_id} = read_u16(cmap, offset) {:ok, encoding_id} = read_u16(cmap, offset + 2) {:ok, subtable_offset} = read_u32(cmap, offset + 4) {platform_id, encoding_id, subtable_offset} end) |> Enum.sort_by(fn {platform_id, encoding_id, _offset} -> cond do platform_id == 3 and encoding_id == 10 -> 0 platform_id == 3 and encoding_id == 1 -> 1 platform_id == 0 -> 2 true -> 3 end end) |> Enum.map(fn {_platform_id, _encoding_id, offset} -> offset end) {:ok, offsets} else _ -> :error end end defp parse_cmap_subtable(cmap, offset) do with true <- byte_size(cmap) >= offset + 2, {:ok, format} <- read_u16(cmap, offset) do case format do 4 -> parse_cmap_format4(cmap, offset) _ -> :error end else _ -> :error end end defp parse_cmap_format4(cmap, offset) do with {:ok, length} <- read_u16(cmap, offset + 2), true <- byte_size(cmap) >= offset + length, {:ok, seg_count_x2} <- read_u16(cmap, offset + 6), true <- seg_count_x2 > 0 and rem(seg_count_x2, 2) == 0 do seg_count = div(seg_count_x2, 2) end_codes_offset = offset + 14 start_codes_offset = end_codes_offset + seg_count * 2 + 2 id_deltas_offset = start_codes_offset + seg_count * 2 id_range_offsets_offset = id_deltas_offset + seg_count * 2 0..(seg_count - 1) |> Enum.reduce_while({:ok, %{}}, fn index, {:ok, acc} -> with {:ok, end_code} <- read_u16(cmap, end_codes_offset + index * 2), {:ok, start_code} <- read_u16(cmap, start_codes_offset + index * 2), {:ok, id_delta} <- read_i16(cmap, id_deltas_offset + index * 2), {:ok, range_offset} <- read_u16(cmap, id_range_offsets_offset + index * 2), true <- start_code <= end_code do mappings = start_code..end_code |> Enum.reject(&(&1 == 0xFFFF)) |> Enum.reduce(acc, fn codepoint, mappings -> glyph_id = cmap_format4_glyph_id( cmap, codepoint, start_code, id_delta, range_offset, id_range_offsets_offset + index * 2 ) case glyph_id do glyph_id when is_integer(glyph_id) and glyph_id > 0 -> Map.put(mappings, codepoint, glyph_id) _ -> mappings end end) {:cont, {:ok, mappings}} else _ -> {:halt, :error} end end) else _ -> :error end end defp cmap_format4_glyph_id( cmap, codepoint, start_code, id_delta, range_offset, range_word_offset ) do case range_offset do 0 -> rem(codepoint + id_delta, 65_536) range_offset -> glyph_offset = range_word_offset + range_offset + 2 * (codepoint - start_code) case read_u16(cmap, glyph_offset) do {:ok, 0} -> 0 {:ok, glyph_id} -> rem(glyph_id + id_delta, 65_536) :error -> 0 end end end defp glyph_width(font, glyph_id) do Enum.at(font.widths, glyph_id, font.default_width) end defp pdf_safe_name(name) do name |> String.replace(~r/[^A-Za-z0-9_-]/u, "") |> case do "" -> "EmbeddedFont" safe -> safe end end defp read_u16(data, offset) do case byte_size(data) >= offset + 2 do true -> <> = binary_part(data, offset, 2) {:ok, value} false -> :error end end defp read_i16(data, offset) do case byte_size(data) >= offset + 2 do true -> <> = binary_part(data, offset, 2) {:ok, value} false -> :error end end defp read_u32(data, offset) do case byte_size(data) >= offset + 4 do true -> <> = binary_part(data, offset, 4) {:ok, value} false -> :error end end end