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) 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) %{"/Metadata 5 0 R" => ["5 0 obj\..."]} """ @spec raw_metadata_objects(binary) :: map 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) |> Enum.map(fn [_, key, val] -> {key, val} end) hex = Regex.scan(~r{/([^ /]+)\s*<(.*?)>}s, string) |> Enum.map(fn [_, key, val] -> {key, val} end) Enum.concat(strings, hex) |> Enum.reduce([], fn {key, "feff" <> base_16_encoded_utf16_big_endian}, acc -> 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) string = :unicode.characters_to_binary(utf16, {:utf16, endianness}) [{key, string} | acc] :error -> acc end {key, <<254, 255>> <> utf16}, acc -> endianness = determine_endianness(utf16, :big) utf16 = utf16_size_fix(utf16) string = :unicode.characters_to_binary(utf16, {:utf16, endianness}) [{key, string} | acc] {key, "\\376\\377" <> rest}, acc -> # Fix metadata: https://github.com/mozilla/pdf.js/pull/1598/files#diff-7f3b58adf9e7b7e802f63cc9b3855506R7 [{key, fix_null_padded_utf16(rest)} | acc] {key, val}, acc -> [{key, val} | acc] end) |> Enum.map(fn {key, val} -> { key, 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) |> Map.new() end @doc false @spec fix_null_padded_utf16(binary) :: binary def fix_null_padded_utf16(binary) when is_binary(binary) do string = fix_null_padding(binary) endianness = string |> determine_endianness(:big) :unicode.characters_to_binary(string, {:utf16, endianness}) 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_null_padding(binary) do fix_null_padding(binary, "") end def fix_null_padding(<<>>, acc) do acc end @d1_digits Enum.map(0..3, &to_string/1) @d2_and_d3_digits Enum.map(0..7, &to_string/1) def fix_null_padding( "\\" <> <> <> <> <> <> <> rest, acc ) when d1 in @d1_digits and d2 in @d2_and_d3_digits and d3 in @d2_and_d3_digits do code = String.to_integer(d1) * 64 + String.to_integer(d2) * 8 + String.to_integer(d3) * 1 fix_null_padding(rest, acc <> <>) end def fix_null_padding(<> <> rest, acc) do fix_null_padding(rest, acc <> byte) 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 -> val = Regex.scan(~r{([^<\n].*?) Enum.min_by(fn [_, inner_val] -> byte_size(inner_val) end, fn -> [] end) |> case do [_, inner_val] -> inner_val [] -> val end |> String.trim() Map.put(acc, {type, key}, val) _, 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