defmodule Pdf.Reader.Font do @moduledoc """ Per-font decoder construction for the encoding cascade. A "decoder" is a closure `(binary -> {String.t(), [{non_neg_integer(), binary()}]})` that maps raw font-code bytes to UTF-8 text plus a list of unresolved sentinels. ## Simple fonts (Type1, TrueType, etc.) Cascade per byte (delegates to `Pdf.Reader.Encoding.resolve_byte/3`): ToUnicode CMap → /Differences + AGL → base encoding → U+FFFD + sentinel. ## Composite fonts (Type0/Identity-H/V) When `/Encoding` is `Identity-H` or `Identity-V`, the font is dispatched to `Pdf.Reader.CID.Decoder.build/2`. The CID decoder consumes bytes in 2-byte big-endian chunks and resolves via: ToUnicode CMap → Adobe registry table (Japan1/CNS1/Korea1/GB1) → U+FFFD. Non-Identity predefined CMaps (`UniJIS-UTF16-H`, `GBK-EUC-H`, etc.) are also supported when bundled in `priv/cmap/` — the decoder dispatches to `Pdf.Reader.CID.Decoder.build_predefined/2` which uses `Pdf.Reader.CID.PredefinedCMap` for byte→CID lookup followed by the same Adobe registry → Unicode resolution as Identity-H/V. ## Cache Decoders for fonts referenced by indirect ref `{:ref, n, g}` are cached in `Document.cache` under key `{:font_decoder, {n, g}}` for reuse across pages with shared font resources. Inline font dicts (plain maps, no ref) are NOT cached. ## Recovery mode (R-2) When `doc.recover_mode` is `true` and a font dict fails to resolve or build, `build_decoders_for_resources/2` installs a fallback U+FFFD identity decoder for that font instead of returning `{:error, _}`. The fallback emits `<<0xFFFD::utf8>>` per input byte, which guarantees `String.valid?/1` is `true` on the resulting text. A `{:font_skipped, page_n, font_name, reason}` event is logged to `doc.recovery_log` for each failed font. Fonts that build successfully are NOT affected. Spec: PDF 1.7 § 9.6 (font dictionaries), § 9.10 (text content extraction). ## Spec references - PDF 1.7 § 9.6 — Type 1 Fonts: https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf - PDF 1.7 § 9.6.5, § 9.6.5.1 — Character Encoding, /Differences arrays - PDF 1.7 § 9.7 — Composite Fonts (Type0, CIDFonts, CMaps) - PDF 1.7 § 9.7.4 — CIDFonts - PDF 1.7 § 9.7.5 — Predefined CMaps (Identity-H, Identity-V) - PDF 1.7 § 9.10.3 — ToUnicode CMaps """ alias Pdf.Reader.CID alias Pdf.Reader.CID.PredefinedCMap alias Pdf.Reader.{CMap, Document, Encoding, Filter, ObjectResolver} alias Pdf.Reader.Encoding.Differences @type decoder_fn :: (binary() -> {String.t(), [{non_neg_integer(), binary()}]}) @doc """ Build a decoder closure for a font. Accepts either: - A `font_dict` (plain map) — inline font, built directly without caching. - A `{:ref, n, g}` tuple — indirect font reference; result is cached in `doc.cache` under `{:font_decoder, {n, g}}`. Returns `{:ok, decoder_fn, updated_doc}`. """ @spec build_decoder(map() | {:ref, pos_integer(), non_neg_integer()}, Document.t()) :: {:ok, decoder_fn(), Document.t()} | {:error, term()} def build_decoder({:ref, n, g} = font_ref, doc) do cache_key = {:font_decoder, {n, g}} case Map.get(doc.cache, cache_key) do nil -> # Cache miss: resolve the font dict, build the decoder, store it. with {:ok, font_dict, doc2} <- ObjectResolver.resolve(doc, font_ref) do {decoder, doc3} = build_decoder_internal(font_dict, doc2) cached_doc = %{doc3 | cache: Map.put(doc3.cache, cache_key, decoder)} {:ok, decoder, cached_doc} end cached_decoder -> {:ok, cached_decoder, doc} end end def build_decoder(font_dict, doc) when is_map(font_dict) do {decoder, doc2} = build_decoder_internal(font_dict, doc) {:ok, decoder, doc2} end @doc """ Build decoders for all fonts in a page's resources map. Walks `resources["Font"]` (a map of font name → font dict or ref) and calls `build_decoder/2` for each entry. Returns a map keyed by font name. In strict mode (`doc.recover_mode == false`): returns `{:ok, decoders, [], doc}` on success, or `{:error, reason}` on first font build failure (unchanged). In recovery mode (`doc.recover_mode == true`): on per-font build failure, installs a per-byte U+FFFD fallback decoder for that font name and appends `{font_name, reason}` to the returned `font_failures` list. The page is NOT aborted. The caller is responsible for converting failures to `{:font_skipped, page_n, font_name, reason}` events and logging them. Returns `{:ok, %{font_name => decoder_fn}, [{font_name, reason}], updated_doc}`. ## Spec references - PDF 1.7 § 9.6 — Font dictionaries - PDF 1.7 § 9.10 — Extraction of text content """ @spec build_decoders_for_resources(map(), Document.t()) :: {:ok, %{binary() => decoder_fn()}, [{binary(), term()}], Document.t()} | {:error, term()} def build_decoders_for_resources(resources, doc) do font_map = Map.get(resources, "Font", %{}) |> normalize_font_map() if doc.recover_mode do # R-2: lenient path — per-font try/rescue; install fallback decoder on failure. Enum.reduce(font_map, {:ok, %{}, [], doc}, fn {name, font_ref_or_dict}, {:ok, acc_decoders, acc_failures, acc_doc} -> case build_decoder(font_ref_or_dict, acc_doc) do {:ok, decoder, doc2} -> {:ok, Map.put(acc_decoders, name, decoder), acc_failures, doc2} {:error, reason} -> # Install per-byte U+FFFD fallback decoder (String.valid?/1 guaranteed true). fallback = fn bytes -> {String.duplicate("�", byte_size(bytes)), []} end {:ok, Map.put(acc_decoders, name, fallback), [{name, reason} | acc_failures], acc_doc} end end) else # Strict path — halt on first font build failure (unchanged behavior). result = Enum.reduce_while(font_map, {:ok, %{}, doc}, fn {name, font_ref_or_dict}, {:ok, acc, acc_doc} -> case build_decoder(font_ref_or_dict, acc_doc) do {:ok, decoder, doc2} -> {:cont, {:ok, Map.put(acc, name, decoder), doc2}} {:error, _reason} = err -> {:halt, err} end end) case result do {:ok, decoders, doc2} -> {:ok, decoders, [], doc2} {:error, _} = err -> err end end end # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- # Resolve the Font resource if it is itself an indirect ref (pointing to a dict). defp normalize_font_map(font_map_or_ref) when is_map(font_map_or_ref), do: font_map_or_ref defp normalize_font_map(_), do: %{} # Core decoder construction from a resolved font dict. @spec build_decoder_internal(map(), Document.t()) :: {decoder_fn(), Document.t()} defp build_decoder_internal(font_dict, doc) do case cid_font_type(font_dict) do :identity -> # Identity-H/V path: fixed 2-byte CID loop (existing path — R-CID1, R-CID2). {:ok, decoder, doc2} = CID.Decoder.build(font_dict, doc) {decoder, doc2} {:predefined, _name} -> # Predefined CMap path: variable-length tokenization via codespace (R-PCM1). case CID.Decoder.build_predefined(font_dict, doc) do {:ok, decoder, doc2} -> {decoder, doc2} {:error, {:not_bundled, _}} -> # Should not happen since cid_font_type/1 already checked bundled?, # but handle gracefully. build_simple_decoder(font_dict, doc) {:error, _} -> build_simple_decoder(font_dict, doc) end :not_cid -> build_simple_decoder(font_dict, doc) end end # Detect the CID font type: # - :identity — Type0 with Identity-H or Identity-V # - {:predefined, name} — Type0 with a bundled predefined CMap name (R-PCM15, D2) # - :not_cid — anything else defp cid_font_type(font_dict) do case Map.get(font_dict, "Encoding") do {:name, "Identity-H"} -> :identity {:name, "Identity-V"} -> :identity {:name, name} when is_binary(name) -> if PredefinedCMap.bundled?(name), do: {:predefined, name}, else: :not_cid _ -> :not_cid end end # Simple (1-byte) font decoder — existing path, unchanged. defp build_simple_decoder(font_dict, doc) do # Step 1: Resolve ToUnicode CMap (if any). {cmap, doc2} = resolve_cmap(font_dict, doc) # Step 2: Resolve /Encoding → base atom + differences map. {base_encoding, differences} = resolve_encoding(font_dict) # Step 3: Build closure that applies the cascade for each byte. decoder = build_closure(cmap, base_encoding, differences) {decoder, doc2} end # --------------------------------------------------------------------------- # CMap resolution # --------------------------------------------------------------------------- # Test shortcut: pre-parsed CMap stored under "__test_cmap__" key. defp resolve_cmap(%{"__test_cmap__" => %CMap{} = cmap}, doc), do: {cmap, doc} defp resolve_cmap(font_dict, doc) do case Map.get(font_dict, "ToUnicode") do nil -> {nil, doc} {:ref, _, _} = ref -> resolve_cmap_ref(ref, doc) # Already decoded binary (unlikely from parser, but handle gracefully) binary when is_binary(binary) -> {CMap.parse(binary), doc} end end defp resolve_cmap_ref(ref, doc) do with {:ok, {:stream, dict, raw_bytes}, doc2} <- ObjectResolver.resolve(doc, ref), filter = Map.get(dict, "Filter"), parms = Map.get(dict, "DecodeParms"), {:ok, decoded} <- decode_stream(raw_bytes, filter, parms) do {CMap.parse(decoded), doc2} else _ -> {nil, doc} end end defp decode_stream(raw_bytes, nil, _parms), do: {:ok, raw_bytes} defp decode_stream(raw_bytes, filter, parms) do Filter.apply_chain(raw_bytes, filter, parms || %{}) end # --------------------------------------------------------------------------- # Encoding resolution # --------------------------------------------------------------------------- # No /Encoding key → nil base, nil differences defp resolve_encoding(%{"Encoding" => encoding}) do resolve_encoding_value(encoding) end defp resolve_encoding(_), do: {nil, nil} # {:name, "WinAnsiEncoding"} → :win_ansi base, no differences defp resolve_encoding_value({:name, name}) when is_binary(name) do {name_to_base_atom(name), nil} end # Encoding dict (inline map) with optional BaseEncoding + Differences defp resolve_encoding_value(enc_dict) when is_map(enc_dict) do base_name = case Map.get(enc_dict, "BaseEncoding") do {:name, n} when is_binary(n) -> n n when is_binary(n) -> n _ -> nil end base_atom = if base_name, do: name_to_base_atom(base_name), else: nil differences = case Map.get(enc_dict, "Differences") do nil -> nil diffs when is_list(diffs) -> Differences.apply(%{}, diffs) end {base_atom, differences} end defp resolve_encoding_value(_), do: {nil, nil} defp name_to_base_atom("WinAnsiEncoding"), do: :win_ansi defp name_to_base_atom("MacRomanEncoding"), do: :mac_roman defp name_to_base_atom("StandardEncoding"), do: :standard defp name_to_base_atom(_), do: nil # --------------------------------------------------------------------------- # Closure builder # --------------------------------------------------------------------------- @unresolved_char "�" defp build_closure(cmap, base_encoding, differences) do fn bytes -> decode_bytes(bytes, cmap, base_encoding, differences) end end defp decode_bytes(bytes, cmap, base_encoding, differences) do {text_iodata, unresolved, _idx} = for <>, reduce: {[], [], 0} do {text_acc, unresolved_acc, idx} -> case Encoding.resolve_byte(byte, cmap, differences: differences, base_encoding: base_encoding ) do {:ok, codepoint} -> {[text_acc, <>], unresolved_acc, idx + 1} {:unresolved, marker} -> {[text_acc, @unresolved_char], [{idx, marker} | unresolved_acc], idx + 1} end end text = IO.iodata_to_binary(text_iodata) unresolved_list = Enum.reverse(unresolved) {text, unresolved_list} end end