defmodule PDFInfo do # For backward compatibility, applications that create PDF 1.4 documents should include the metadata for a document in the document information dictionary as well as in the document’s metadata stream. Applications that support PDF 1.4 or later should check for the existence of a metadata stream and synchronize the information in it with that in the document information dictionary. The Adobe metadata framework provides a date stamp for metadata expressed in the framework. If this date stamp is equal to or later than the document modification date recorded in the document information dictionary, the metadata stream shall be taken as authoritative. If, however, the document modification date recorded in the document information dictionary is later than the metadata stream’s date stamp, the document has likely been saved by an application that is not aware of PDF 1.4 metadata streams. In this case, information stored in the document information dictionary should be taken to override any semantically equivalent items in the metadata stream. @moduledoc """ Extracts all /Info and /Metadata objects from a PDF binary using Regex and without any external dependencies. """ @doc """ Checks if the binary starts with the PDF header. Returns `true` if the binary starts with the PDF header. Returns `false` otherwise. """ @spec is_pdf?(binary) :: boolean def is_pdf?(<<"%PDF">> <> _), do: true def is_pdf?(_), do: false @doc """ Extracts PDF version from the PDF header. Returns `{:ok, version}` if the PDF header is correct. Returns `:error` if the PDF header is incorrect. ## Examples iex> PDFInfo.pdf_version(binary) {:ok, "1.5"} iex> PDFInfo.pdf_version("not a pdf") :error """ @spec pdf_version(binary) :: {:ok, binary} | :error def pdf_version(<<"%PDF-">> <> <> <> _) do {:ok, version} end def pdf_version(_) do :error end @doc """ Returns a list of /Encrypt reference strings. ## Examples iex> PDFInfo.encrypt_refs(binary) ["/Encrypt 52 0 R"] """ @spec encrypt_refs(binary) :: list def encrypt_refs(binary) when is_binary(binary) do Regex.scan(~r{[/]Encrypt[ ]*[0-9].*?R}, binary) |> Enum.flat_map(& &1) |> Enum.uniq() end @doc """ Returns `true` if PDF has at least one /Encrypt reference. Returns `false` if PDF has no /Encrypt reference. """ @spec is_encrypted?(binary) :: boolean def is_encrypted?(binary) when is_binary(binary) do case encrypt_refs(binary) do [] -> false _ -> true end end @doc """ Returns a list of /Info reference strings. ## Examples iex> PDFInfo.info_refs(binary) ["/Info 1 0 R"] """ @spec info_refs(binary) :: list def info_refs(binary) when is_binary(binary) do Regex.scan(~r{[/]Info[\s0-9]*?R}, binary) |> Enum.flat_map(& &1) |> Enum.uniq() end @doc """ Returns a list of /Metadata reference strings. ## Examples iex> PDFInfo.metadata_refs(binary) ["/Metadata 5 0 R"] """ @spec metadata_refs(binary) :: list def metadata_refs(binary) when is_binary(binary) do Regex.scan(~r{[/]Metadata[\s0-9]*?R}, binary) |> Enum.flat_map(& &1) |> Enum.uniq() end @doc """ Maps /Info reference strings to objects and parses the objects. ## Examples iex> PDFInfo.info_objects(binary) %{ "/Info 1 0 R" => [ %{ "Author" => "The PostgreSQL Global Development Group", "CreationDate" => "D:20200212212756Z", ... } ] } """ @spec info_objects(binary) :: map def info_objects(binary) when is_binary(binary) do binary |> raw_info_objects() |> Enum.reduce(%{}, fn {info_ref, list}, acc -> Map.put(acc, info_ref, Enum.map(list, &parse_info_object/1)) end) end @doc """ Maps /Metadata reference strings to objects and parses the objects. ## Examples iex> PDFInfo.metadata_objects(binary) %{ "/Metadata 285 0 R" => [ %{ {"dc", "format"} => "application/pdf", {"pdf", "Producer"} => "Adobe PDF Library 15.0", {"xmp", "CreateDate"} => "2018-06-06T17:02:53+02:00", {"xmp", "CreatorTool"} => "Acrobat PDFMaker 17 für Word", {"xmp", "MetadataDate"} => "2018-06-06T17:03:13+02:00", {"xmp", "ModifyDate"} => "2018-06-06T17:03:13+02:00", ... } ] } """ @spec metadata_objects(binary) :: map def metadata_objects(binary) when is_binary(binary) do binary |> raw_metadata_objects() |> Enum.map(&parse_metadata_object/1) |> Enum.reject(&Enum.empty?(&1)) end @doc """ Maps the /Info reference strings to the raw objects. ## Examples iex> PDFInfo.raw_info_objects(binary) %{"/Info 1 0 R" => ["1 0 obj <<..."]} """ @spec raw_info_objects(binary) :: map def raw_info_objects(binary) when is_binary(binary) do binary |> info_refs() |> Enum.reduce(%{}, fn info_ref, acc -> obj_id = info_ref |> String.trim_leading("/Info ") |> String.trim_trailing(" R") list = get_object(binary, obj_id) |> Enum.flat_map(& &1) |> Enum.uniq() Map.put(acc, info_ref, list) end) end @doc """ Maps the /Metadata reference strings to the raw objects. ## Examples iex> PDFInfo.raw_metadata_objects(binary) [" ...] """ @spec raw_metadata_objects(binary) :: list def raw_metadata_objects(binary) when is_binary(binary) do Enum.zip( Regex.scan(~r{ Enum.reduce([], fn {[{start_position, _}], [{end_position, _}]}, acc when start_position < end_position -> raw_meta = binary_part(binary, start_position, end_position - start_position) <> "" [raw_meta | acc] _, acc -> acc end) end @doc false def parse_info_object(string) when is_binary(string) do strings = Regex.scan(~r{/(.*?)\s*\((.*?)\)}, string) hex = Regex.scan(~r{/([^ /]+)\s*<(.*?)>}s, string) Enum.concat(strings, hex) |> Enum.reduce([], fn [_, key, val], acc -> case decode_value(val) do {:ok, val} -> [{key, val} | acc] :error -> acc end end) |> Enum.map(fn {key, val} -> {key, val |> fix_non_printable() |> fix_octals()} end) |> Map.new() end def decode_value("feff" <> base_16_encoded_utf16_big_endian) do base_16_encoded_utf16_big_endian |> String.replace(~r{[^0-9a-f]}, "") |> Base.decode16(case: :lower) |> case do {:ok, utf16} -> endianness = determine_endianness(utf16, :big) utf16 = utf16_size_fix(utf16) {:ok, :unicode.characters_to_binary(utf16, {:utf16, endianness})} :error -> :error end end @doc false def decode_value(<<254, 255>> <> utf16) do endianness = determine_endianness(utf16, :big) utf16 = utf16_size_fix(utf16) {:ok, :unicode.characters_to_binary(utf16, {:utf16, endianness})} end def decode_value("\\376\\377" <> rest) do # Fix metadata: https://github.com/mozilla/pdf.js/pull/1598/files#diff-7f3b58adf9e7b7e802f63cc9b3855506R7 rest |> fix_octal_utf16() |> case do string when is_binary(string) -> {:ok, string} error -> {:error, error} end end def decode_value(val) do {:ok, val} end # for info object only @doc false def fix_non_printable(val) when is_binary(val) do case String.printable?(val) do false -> # Fix for non printable binary like <<252>> = ü val |> :binary.bin_to_list() |> :unicode.characters_to_binary() true -> val end end @doc false def utf16_size_fix(binary) do case rem(byte_size(binary), 2) do 0 -> binary 1 -> binary <> <<0>> end end @doc false def fix_octal_utf16(binary) when is_binary(binary) do String.split(binary, ~r{\\[0-3][0-7][0-7]}, include_captures: true, trim: true) |> Enum.map(&do_fix_octal_utf16/1) |> Enum.chunk_every(2) |> Enum.map(fn [left_byte, right_byte] -> <> _ -> <<>> end) |> Enum.join() |> :unicode.characters_to_binary({:utf16, :big}) end @octal_first_digit 0..3 |> Enum.map(&to_string/1) @octal_rest_digits 0..7 |> Enum.map(&to_string/1) @doc false def do_fix_octal_utf16("\\" <> <> <> <> <> <>) when d1 in @octal_first_digit and d2 in @octal_rest_digits and d3 in @octal_rest_digits do String.to_integer(d1) * 64 + String.to_integer(d2) * 8 + String.to_integer(d3) * 1 end def do_fix_octal_utf16(<>) do code end @doc false def fix_octals(binary) when is_binary(binary) do String.split(binary, ~r{\\[0-3][0-7][0-7]}, include_captures: true) |> Enum.map(&do_fix_octal/1) |> Enum.join() end @doc false def do_fix_octal("\\" <> <> <> <> <> <>) when d1 in @octal_first_digit and d2 in @octal_rest_digits and d3 in @octal_rest_digits do code = String.to_integer(d1) * 64 + String.to_integer(d2) * 8 + String.to_integer(d3) * 1 <> end def do_fix_octal(binary) do binary end @doc false def parse_metadata_object(string) when is_binary(string) do with [xmp] <- Regex.run(~r{ Enum.reduce(%{}, fn tag, acc -> list = Regex.scan(~r{<#{tag}:(.*?)>(.*?)}sm, xmp) reduce_metadata(acc, tag, list) end) else _ -> :error end end @doc false def reduce_metadata(acc, type, list) do list |> Enum.reduce( acc, fn [_, key, val, key], acc -> # remove rdf tags like , , val = val |> String.replace(~r{<[a-z]+:[a-z]+>}i, " ") |> String.replace(~r{<[a-z]+:.+["']>}i, " ") |> String.replace(~r{}i, " ") |> String.replace(~r{<[a-z]+:.+/>}i, " ") |> String.trim() case decode_value(val) do {:ok, val} -> Map.put(acc, {type, key}, val) :error -> acc end _, acc -> acc end ) end @doc false def get_object(binary, obj_id) when is_binary(binary) and is_binary(obj_id) do obj_id = String.replace(obj_id, " ", "\s") ~r{[^0-9]#{obj_id}.obj(?s).*?endobj} |> Regex.scan(binary) end # it can have messed up byte order marker @doc false def determine_endianness(binary, initial_guess) do determine_endianness(binary, initial_guess, 0, 0) end defp determine_endianness(<<>>, _, little_zeros, big_zeros) do case {little_zeros, big_zeros} do {lz, bz} when lz > bz -> :little {lz, bz} when bz >= lz -> :big end end defp determine_endianness(<<0>> <> binary, :little, little_zeros, big_zeros) do determine_endianness(binary, :big, little_zeros + 1, big_zeros) end defp determine_endianness(<<0>> <> binary, :big, little_zeros, big_zeros) do determine_endianness(binary, :little, little_zeros, big_zeros + 1) end defp determine_endianness(<<_>> <> binary, :little, little_zeros, big_zeros) do determine_endianness(binary, :big, little_zeros, big_zeros) end defp determine_endianness(<<_>> <> binary, :big, little_zeros, big_zeros) do determine_endianness(binary, :little, little_zeros, big_zeros) end end