defmodule Pdf.Reader.Font.Widths do @moduledoc """ Per-font glyph-width lookup for text advance computation. Builds closures of type `(binary() -> [non_neg_integer()])` that return, for a binary of raw font bytes, a list of glyph-space advance widths (one per glyph code encoded in the binary). ## Simple fonts (Type1, TrueType) Width lookup uses `/Widths`, `/FirstChar`, `/LastChar` from the font dict. Out-of-range codes fall back to `/MissingWidth` from `/FontDescriptor`, or `0` if absent. (§ 9.6.2.1, § 9.6.4) ### Standard-14 fallback When `/Widths` is entirely absent — typical of Standard 14 Type 1 fonts (Helvetica, Times-Roman, Courier, Symbol, ZapfDingbats and their styled variants) — the spec (§ 9.6.2.2) requires the reader to use bundled AFM metrics. We do not bundle AFMs (documented gap), so every glyph is approximated as **500 units (~0.5 em)**, the rough average across the Adobe Standard 14 AFM tables. This restores usable text positioning for Standard-14-only PDFs (most government forms, RFCs, simple reports); exact column alignment still requires AFM bundling. Source: Adobe Font Metrics for the Core 14 — public domain via https://github.com/adobe-type-tools/Adobe-Core-14-Font-AFM-Files (avg glyph width across the 14 fonts ≈ 500 units). ## CIDFonts (Type0 → DescendantFonts[0]) Width lookup uses `/W` (Form A and Form B entries) and `/DW` (default: 1000). (§ 9.7.4.3) ## Cache Widths closures for fonts referenced via `{:ref, n, g}` are cached in `Document.cache` under key `{:font_widths, {n, g}}`, mirroring the decoder cache strategy of `Pdf.Reader.Font`. ## Spec references - PDF 1.7 § 9.4.4 — Text advance formula (tx per glyph) - PDF 1.7 § 9.6.2.1 — Simple font /Widths, /FirstChar, /LastChar - PDF 1.7 § 9.6.4 — Font descriptor /MissingWidth - PDF 1.7 § 9.7.4.3 — CIDFont /W and /DW arrays """ alias Pdf.Reader.{Document, ObjectResolver} @type widths_fn :: (binary() -> [non_neg_integer()]) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @doc """ Build a widths closure for a font dict or indirect reference. Returns `{:ok, widths_fn, updated_doc}`. Spec: § 9.6.2.1 (simple fonts), § 9.7.4.3 (CIDFonts). """ @spec build_widths_fn(map() | {:ref, pos_integer(), non_neg_integer()}, Document.t()) :: {:ok, widths_fn(), Document.t()} | {:error, term()} def build_widths_fn({:ref, n, g} = font_ref, doc) do cache_key = {:font_widths, {n, g}} case Map.get(doc.cache, cache_key) do nil -> with {:ok, font_dict, doc2} <- ObjectResolver.resolve(doc, font_ref) do {widths_fn, doc3} = build_widths_internal(font_dict, doc2) cached_doc = %{doc3 | cache: Map.put(doc3.cache, cache_key, widths_fn)} {:ok, widths_fn, cached_doc} end cached_fn -> {:ok, cached_fn, doc} end end def build_widths_fn(font_dict, doc) when is_map(font_dict) do # Test shortcut: if "__test_widths__" key is present, use it directly case Map.get(font_dict, "__test_widths__") do nil -> {widths_fn, doc2} = build_widths_internal(font_dict, doc) {:ok, widths_fn, doc2} fn_or_map when is_function(fn_or_map, 1) -> {:ok, fn_or_map, doc} end end @doc """ Build widths closures for all fonts in a page's resources map. Mirrors `Pdf.Reader.Font.build_decoders_for_resources/2`. Returns `{:ok, %{font_name => widths_fn}, updated_doc}`. When `doc.recover_mode` is `true`: on per-font widths failure, installs a zero-width fallback (all glyphs advance 0) and continues. The font_skipped event is already logged by `build_decoders_for_resources`; no duplicate event is emitted here. When `doc.recover_mode` is `false`: halts on first font widths failure (unchanged strict behavior). """ @spec build_widths_for_resources(map(), Document.t()) :: {:ok, %{binary() => widths_fn()}, Document.t()} | {:error, term()} def build_widths_for_resources(resources, doc) do font_map = Map.get(resources, "Font", %{}) |> normalize_font_map() if doc.recover_mode do # R-2: lenient path — install zero-width fallback on failure; never halt. result = Enum.reduce(font_map, {:ok, %{}, doc}, fn {name, font_ref_or_dict}, {:ok, acc, acc_doc} -> case build_widths_fn(font_ref_or_dict, acc_doc) do {:ok, widths_fn, doc2} -> {:ok, Map.put(acc, name, widths_fn), doc2} {:error, _reason} -> # Zero-width fallback: all bytes advance 0 (no horizontal movement). fallback_widths = fn bytes -> List.duplicate(0, byte_size(bytes)) end {:ok, Map.put(acc, name, fallback_widths), acc_doc} end end) result else Enum.reduce_while(font_map, {:ok, %{}, doc}, fn {name, font_ref_or_dict}, {:ok, acc, acc_doc} -> case build_widths_fn(font_ref_or_dict, acc_doc) do {:ok, widths_fn, doc2} -> {:cont, {:ok, Map.put(acc, name, widths_fn), doc2}} {:error, _reason} = err -> {:halt, err} end end) end end # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- defp normalize_font_map(font_map) when is_map(font_map), do: font_map defp normalize_font_map(_), do: %{} # Dispatch to simple or CID path based on font subtype @spec build_widths_internal(map(), Document.t()) :: {widths_fn(), Document.t()} defp build_widths_internal(font_dict, doc) do case is_cid_font?(font_dict) do true -> parse_cid(font_dict, doc) false -> parse_simple(font_dict, doc) end end defp is_cid_font?(font_dict) do case Map.get(font_dict, "Subtype") do {:name, "Type0"} -> true _ -> false end end # --------------------------------------------------------------------------- # parse_simple/2 — simple fonts (Type1, TrueType, etc.) # Spec: § 9.6.2.1, § 9.6.4 # --------------------------------------------------------------------------- @doc false @spec parse_simple(map(), Document.t()) :: {widths_fn(), Document.t()} def parse_simple(font_dict, doc) do first_char = int_val(Map.get(font_dict, "FirstChar"), 0) last_char = int_val(Map.get(font_dict, "LastChar"), -1) widths_list = list_val(Map.get(font_dict, "Widths"), []) {missing_width, doc2} = resolve_missing_width(font_dict, doc) # PDF 1.7 § 9.6.2.2: when a font has NO embedded /Widths array (typical # of Standard 14 Type 1 fonts), the reader is expected to use bundled # AFM metrics. We don't bundle AFMs (documented gap), so we approximate # every glyph as 500 units (~0.5 em) — the rough average across the # Adobe Core-14 AFM tables. Without this, every glyph would advance by # 0 and `read_text_with_positions/1` would collapse all glyphs onto the # same X coordinate (seen in the wild on government tax PDFs and RFCs). # When /Widths IS present but a code is out of range, the spec-correct # /MissingWidth (default 0) still applies. fallback_width = if widths_list == [], do: 500, else: missing_width widths_fn = fn bytes -> for <> do if byte >= first_char and byte <= last_char do idx = byte - first_char case Enum.at(widths_list, idx) do nil -> missing_width w -> int_val(w, missing_width) end else fallback_width end end end {widths_fn, doc2} end # Resolve /MissingWidth from /FontDescriptor (may be an indirect ref). defp resolve_missing_width(font_dict, doc) do case Map.get(font_dict, "FontDescriptor") do nil -> {0, doc} {:ref, _, _} = ref -> case ObjectResolver.resolve(doc, ref) do {:ok, descriptor, doc2} when is_map(descriptor) -> mw = int_val(Map.get(descriptor, "MissingWidth"), 0) {mw, doc2} _ -> {0, doc} end descriptor when is_map(descriptor) -> mw = int_val(Map.get(descriptor, "MissingWidth"), 0) {mw, doc} _ -> {0, doc} end end # --------------------------------------------------------------------------- # parse_cid/2 — CIDFonts (Type0 with DescendantFonts) # Spec: § 9.7.4.3 # --------------------------------------------------------------------------- @doc false @spec parse_cid(map(), Document.t()) :: {widths_fn(), Document.t()} def parse_cid(font_dict, doc) do {cid_dict, doc2} = resolve_descendant(font_dict, doc) dw = int_val(Map.get(cid_dict, "DW"), 1000) w_array = list_val(Map.get(cid_dict, "W"), []) width_map = parse_w_array(w_array) widths_fn = fn bytes -> cid_widths_for_bytes(bytes, width_map, dw) end {widths_fn, doc2} end # Decode 2-byte big-endian CIDs from bytes and return their widths. defp cid_widths_for_bytes(bytes, width_map, dw) do byte_count = byte_size(bytes) pair_count = div(byte_count, 2) for i <- 0..(pair_count - 1), pair_count > 0 do <<_::binary-size(i * 2), high, low, _::binary>> = bytes cid = high * 256 + low Map.get(width_map, cid, dw) end end defp resolve_descendant(font_dict, doc) do case Map.get(font_dict, "DescendantFonts") do [first | _] when is_map(first) -> {first, doc} [{:ref, _, _} = ref | _] -> case ObjectResolver.resolve(doc, ref) do {:ok, dict, doc2} when is_map(dict) -> {dict, doc2} _ -> {%{}, doc} end _ -> {%{}, doc} end end # --------------------------------------------------------------------------- # parse_w_array/1 — hand-rolled state machine for /W array # Handles Form A (c [w1 w2 …]) and Form B (c1 c2 w) interleaved. # Spec: § 9.7.4.3 # --------------------------------------------------------------------------- @doc """ Parse a CIDFont `/W` array into a `%{cid => width}` map. Supports: - Form A: `c [w1 w2 …]` — CID `c`, `c+1`, … each get successive widths - Form B: `c1 c2 w` — all CIDs from `c1` to `c2` inclusive get width `w` Both forms may be interleaved in the same array. (§ 9.7.4.3) """ @spec parse_w_array([term()]) :: %{non_neg_integer() => non_neg_integer()} def parse_w_array(w_array) do do_parse_w(w_array, %{}) end # State machine: no tokens left defp do_parse_w([], acc), do: acc # Form A: current token is an integer, next is a list → expand list from start_cid defp do_parse_w([c, widths_list | rest], acc) when is_integer(c) and is_list(widths_list) do new_acc = expand_form_a(c, widths_list, acc) do_parse_w(rest, new_acc) end # Form B: three consecutive integers → range defp do_parse_w([c1, c2, w | rest], acc) when is_integer(c1) and is_integer(c2) and is_integer(w) do new_acc = expand_form_b(c1, c2, w, acc) do_parse_w(rest, new_acc) end # Malformed / trailing integer with no valid continuation → skip defp do_parse_w([_c | rest], acc), do: do_parse_w(rest, acc) defp expand_form_a(_cid, [], acc), do: acc defp expand_form_a(cid, [w | rest_widths], acc) do expand_form_a(cid + 1, rest_widths, Map.put(acc, cid, int_val(w, 0))) end defp expand_form_b(c1, c2, w, acc) when c1 > c2, do: Map.put(acc, c1, int_val(w, 0)) defp expand_form_b(c1, c2, w, acc) do Enum.reduce(c1..c2, acc, fn cid, a -> Map.put(a, cid, int_val(w, 0)) end) end # --------------------------------------------------------------------------- # Value coercions # --------------------------------------------------------------------------- defp int_val(n, _default) when is_integer(n), do: n defp int_val(n, _default) when is_float(n), do: trunc(n) defp int_val({:integer, n}, _default), do: n defp int_val({:float, n}, _default), do: trunc(n) defp int_val(nil, default), do: default defp int_val(_, default), do: default defp list_val(l, _default) when is_list(l), do: l defp list_val(_, default), do: default end