defmodule Pdf.Reader.XRef.Stream do @moduledoc """ Parses a PDF 1.5+ compressed cross-reference stream (`/Type /XRef`). Per PDF 1.7 ISO 32000-1 § 7.5.8 "Cross-Reference Streams": - The stream object dictionary must have `/Type /XRef`. - Required fields: `/Size` (total object count), `/W [w1 w2 w3]` (byte widths). - Optional `/Index [first count ...]` — subsection ranges; default `[0 /Size]`. - Optional `/Prev` — byte offset of previous xref section (chain support). - The stream body (after decoding all filters) contains exactly `w1 + w2 + w3` bytes per entry: - Field 1 (w1 bytes): entry type. If w1 = 0, type is implicitly 1. - Field 2 (w2 bytes): meaning depends on type. - Field 3 (w3 bytes): meaning depends on type. Entry types (§ 7.5.8 Table 18): - **Type 0** (free): f2 = next free object number, f3 = generation when reused. - **Type 1** (in-use, classic): f2 = byte offset, f3 = generation number. - **Type 2** (compressed): f2 = object number of containing ObjStm, f3 = index within it. This module decodes the stream body using `Pdf.Reader.Filter.apply_chain/3`, which handles `FlateDecode` and PNG predictors transparently (batch 1 impl). """ alias Pdf.Reader.Filter # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @doc """ Parses a `/Type /XRef` stream object tuple into an xref entries map. Accepts `{:stream, dict, raw_bytes}` where `raw_bytes` is the still-encoded (FlateDecode-compressed) stream body. Returns: - `{:ok, entries_map}` — map of `{obj_num, gen_num} => entry` - `{:error, :not_an_xref_stream}` — dict does not have `/Type /XRef` - `{:error, reason}` — filter/decoding error propagated from the filter chain """ @spec parse({:stream, map(), binary()}) :: {:ok, %{Pdf.Reader.Document.ref() => Pdf.Reader.Document.xref_entry()}} | {:error, term()} def parse({:stream, dict, raw_bytes}) do case dict["Type"] do {:name, "XRef"} -> do_parse(dict, raw_bytes) _ -> {:error, :not_an_xref_stream} end end # --------------------------------------------------------------------------- # Internal # --------------------------------------------------------------------------- defp do_parse(dict, raw_bytes) do # Decode the stream body through the filter chain (usually FlateDecode). with {:ok, decoded} <- decode_stream(dict, raw_bytes) do w = extract_w(dict) index_pairs = extract_index(dict) entries = parse_entries(decoded, w, index_pairs, %{}) {:ok, entries} end end # Apply any filters listed in the stream dict to decode the body. # Filter names may be {:name, "FlateDecode"} tuples (from the parser) or plain # binary strings. Filter.apply_chain/3 handles both via resolve_module/1. defp decode_stream(dict, raw_bytes) do filter = Map.get(dict, "Filter") parms = Map.get(dict, "DecodeParms") if is_nil(filter) do # No filter — body is already in raw (uncompressed) form. {:ok, raw_bytes} else Filter.apply_chain(raw_bytes, filter, parms || %{}) end end # Extract /W as a 3-tuple {w1, w2, w3}. # Per spec, each may be 0 to indicate a missing (defaulted) field. defp extract_w(%{"W" => [w1, w2, w3]}), do: {w1, w2, w3} defp extract_w(_), do: {1, 2, 1} # Extract /Index as a list of {first, count} pairs. # Default: [{0, Size}]. defp extract_index(%{"Index" => index_list, "Size" => _size}) do pair_list(index_list) end defp extract_index(%{"Size" => size}) do [{0, size}] end defp extract_index(_), do: [{0, 0}] defp pair_list([]), do: [] defp pair_list([first, count | rest]), do: [{first, count} | pair_list(rest)] # Walk all subsections in order. defp parse_entries(body, w, index_pairs, acc) do Enum.reduce(index_pairs, {body, acc}, fn {first, count}, {remaining, map} -> {rest, new_map} = read_subsection(remaining, w, first, count, map) {rest, new_map} end) |> elem(1) end # Read `count` entries for the subsection starting at object number `first`. defp read_subsection(body, w, first, count, acc) do Enum.reduce(0..(count - 1), {body, acc}, fn i, {remaining, map} -> {entry, rest} = read_entry(remaining, w, first + i) {rest, Map.put(map, elem(entry, 0), elem(entry, 1))} end) end # Read a single xref stream entry. Returns {{key, value}, rest_binary}. # PDF 1.7 § 7.5.8 Table 18 — entry field semantics. # # Note: Elixir binary pattern matching requires compile-time-known sizes. # We use `read_uint/2` to handle runtime-variable field widths. defp read_entry(body, {w1, w2, w3}, obj_num) do # Read field 1 (type): if w1 == 0, type is implicitly 1 (in_use). {type, after_type} = if w1 == 0 do {1, body} else {t, rest} = read_uint(body, w1) {t, rest} end # Read field 2 (f2). {f2, after_f2} = read_uint(after_type, w2) # Read field 3 (f3): if w3 == 0, default is 0. {f3, after_f3} = if w3 == 0 do {0, after_f2} else read_uint(after_f2, w3) end # Map type → entry shape, key → {obj_num, gen_num}. case type do 0 -> # Type 0: free entry. gen = f3 (gen for next use). key = {obj_num, f3} {{key, :free}, after_f3} 1 -> # Type 1: in_use. f2 = byte offset, f3 = generation. key = {obj_num, f3} {{key, {:in_use, f2, f3}}, after_f3} 2 -> # Type 2: compressed. f2 = ObjStm obj_num, f3 = index within ObjStm. # Generation is always 0 for compressed objects (§ 7.5.8). key = {obj_num, 0} {{key, {:compressed, f2, f3}}, after_f3} _other -> # Unknown type — treat as free (spec says future types may be added). key = {obj_num, f3} {{key, :free}, after_f3} end end # Read `n` bytes from `binary` as a big-endian unsigned integer. # Returns {value, rest_binary}. # Uses binary_part/3 + :binary.decode_unsigned/2 to avoid compile-time # size constraints in pattern matching. defp read_uint(binary, n) when n > 0 and byte_size(binary) >= n do field = binary_part(binary, 0, n) rest = binary_part(binary, n, byte_size(binary) - n) value = :binary.decode_unsigned(field, :big) {value, rest} end end