defmodule Mv.Membership.MembersPDF do @moduledoc """ Exports members to PDF using Typst templates and Imprintor. Uses the same data structure as `MemberExport.Build` and converts it to the format expected by the Typst template. Handles internationalization for PDF-specific labels (title, metadata) and membership fee status. Ensures deterministic output by maintaining column and row order. Creates a temporary directory per request and copies the template there to avoid symlink issues and ensure isolation. """ require Logger use Gettext, backend: MvWeb.Gettext alias Mv.Config @template_filename "members_export.typ" @doc """ Renders export data to PDF binary. - `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta` - `opts` - Keyword list with `:locale` (default: "en") and `:club_name` (default: "Club") Returns `{:ok, binary}` where binary is the PDF content, or `{:error, term}`. The PDF binary starts with "%PDF" (PDF magic bytes). Validates row count against configured limit before processing. """ @spec render(map(), keyword()) :: {:ok, binary()} | {:error, term()} def render(export_data, opts \\ []) do row_count = length(export_data.rows) max_rows = Config.pdf_export_row_limit() if row_count > max_rows do Logger.warning( "PDF export rejected: row count exceeds limit (rows: #{row_count}, max: #{max_rows})", error_type: :row_limit_exceeded ) {:error, {:row_limit_exceeded, row_count, max_rows}} else Logger.info( "Starting PDF export (rows: #{row_count}, columns: #{length(export_data.columns)})" ) locale = Keyword.get(opts, :locale, "en") club_name = Keyword.get(opts, :club_name, "Club") create_and_use_temp_directory(export_data, locale, club_name) end end defp create_and_use_temp_directory(export_data, locale, club_name) do case create_temp_directory() do {:ok, temp_dir} -> try do with {:ok, template_content} <- load_template(), {:ok, _template_path} <- copy_template_to_temp(temp_dir, template_content), {:ok, template_data} <- convert_to_template_format(export_data, locale, club_name), {:ok, config} <- build_imprintor_config(template_content, template_data, temp_dir), {:ok, pdf_binary} <- compile_to_pdf(config) do Logger.info("PDF export completed successfully (rows: #{length(export_data.rows)})") {:ok, pdf_binary} else {:error, reason} = error -> Logger.error("PDF export failed: #{inspect(reason)}", error_type: :pdf_export_failed ) error end after cleanup_temp_directory(temp_dir) end {:error, reason} = error -> Logger.error("Failed to create temp directory: #{inspect(reason)}", error_type: :temp_dir_creation_failed ) error end end defp create_temp_directory do # Create unique temp directory per request temp_base = System.tmp_dir!() temp_dir = Path.join(temp_base, "mv_pdf_export_#{System.unique_integer([:positive])}") case File.mkdir_p(temp_dir) do :ok -> {:ok, temp_dir} {:error, reason} -> {:error, {:temp_dir_creation_failed, reason}} end end defp load_template do # Try multiple paths: compiled app path and source path (for tests/dev) template_paths = [ Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/#{@template_filename}"), Path.join([File.cwd!(), "priv", "pdf_templates", @template_filename]) ] Enum.reduce_while(template_paths, nil, fn path, _acc -> case File.read(path) do {:ok, content} -> {:halt, {:ok, content}} {:error, _reason} -> {:cont, nil} end end) |> case do {:ok, content} -> {:ok, content} nil -> {:error, {:template_not_found, :enoent}} end end defp copy_template_to_temp(temp_dir, template_content) do # Write template to temp directory (no symlinks, actual file copy) template_path = Path.join(temp_dir, @template_filename) case File.write(template_path, template_content) do :ok -> {:ok, template_path} {:error, reason} -> {:error, {:template_copy_failed, reason}} end end defp cleanup_temp_directory(temp_dir) do # Clean up temp directory and all contents case File.rm_rf(temp_dir) do {:ok, _} -> :ok {:error, reason, _} -> Logger.warning("Failed to cleanup temp directory: #{temp_dir}, error: #{inspect(reason)}") end end defp convert_to_template_format(export_data, locale, club_name) do # Set locale for translations Gettext.put_locale(MvWeb.Gettext, locale) headers = Enum.map(export_data.columns, & &1.label) column_count = length(export_data.columns) meta = Map.get(export_data, :meta) || Map.get(export_data, "meta") || %{} generated_at_raw = Map.get(meta, :generated_at) || Map.get(meta, "generated_at") || DateTime.utc_now() |> DateTime.to_iso8601() generated_at = format_datetime(generated_at_raw, locale) member_count = Map.get(meta, :member_count) || Map.get(meta, "member_count") || length(export_data.rows) # Translate membership fee status and format dates in rows rows = export_data.rows |> translate_membership_fee_status_in_rows(export_data.columns) |> format_dates_in_rows(export_data.columns, locale) # Build title based on locale title = build_title(locale, club_name) # Build translated labels for metadata created_at_label = gettext("Created at:") member_count_label = gettext("Member count:") template_data = %{ "title" => title, "created_at_label" => created_at_label, "member_count_label" => member_count_label, "generated_at" => generated_at, "column_count" => column_count, "headers" => headers, "rows" => rows, "columns" => Enum.map(export_data.columns, fn col -> %{ "key" => to_string(col.key), "kind" => to_string(col.kind), "label" => col.label } end), "meta" => %{ "generated_at" => generated_at, "member_count" => member_count }, "locale" => locale } {:ok, template_data} end defp build_title(_locale, club_name) do gettext("Member %{club_name}", club_name: club_name) end defp format_datetime(iso8601_string, locale) when is_binary(iso8601_string) do # Try to parse as DateTime first case DateTime.from_iso8601(iso8601_string) do {:ok, datetime, _offset} -> format_datetime(datetime, locale) {:ok, datetime} -> format_datetime(datetime, locale) {:error, _} -> # Try NaiveDateTime if DateTime parsing fails case NaiveDateTime.from_iso8601(iso8601_string) do {:ok, naive_dt} -> # Convert to DateTime in UTC datetime = DateTime.from_naive!(naive_dt, "Etc/UTC") format_datetime(datetime, locale) {:error, _} -> # If both fail, return original string iso8601_string end end end defp format_datetime(%DateTime{} = datetime, locale) do # Format as readable date and time (locale-specific) case locale do "de" -> # German format: dd.mm.yyyy - HH:MM Uhr Calendar.strftime(datetime, "%d.%m.%Y - %H:%M Uhr") _ -> # English format: MM/DD/YYYY HH:MM AM/PM Calendar.strftime(datetime, "%m/%d/%Y %I:%M %p") end end defp format_datetime(_, _), do: "" defp format_date(%Date{} = date, locale) do # Format as readable date (locale-specific) case locale do "de" -> # German format: dd.mm.yyyy Calendar.strftime(date, "%d.%m.%Y") _ -> # English format: MM/DD/YYYY Calendar.strftime(date, "%m/%d/%Y") end end defp format_date(_, _), do: "" defp format_dates_in_rows(rows, columns, locale) do date_indices = find_date_column_indices(columns) if date_indices == [] do rows else format_rows_dates(rows, date_indices, locale) end end defp find_date_column_indices(columns) do columns |> Enum.with_index() |> Enum.filter(fn {col, _idx} -> date_column?(col) end) |> Enum.map(fn {_col, idx} -> idx end) end defp format_rows_dates(rows, date_indices, locale) do Enum.map(rows, fn row -> format_row_dates(row, date_indices, locale) end) end defp format_row_dates(row, date_indices, locale) do Enum.with_index(row) |> Enum.map(fn {cell_value, idx} -> if idx in date_indices do format_cell_date(cell_value, locale) else cell_value end end) end defp date_column?(%{kind: :member_field, key: key}) do key_atom = key_to_atom_safe(key) key_atom in [:join_date, :exit_date, :membership_fee_start_date] end defp date_column?(_), do: false defp key_to_atom_safe(key) when is_binary(key) do try do String.to_existing_atom(key) rescue ArgumentError -> key end end defp key_to_atom_safe(key), do: key defp format_cell_date(cell_value, locale) when is_binary(cell_value) do format_cell_date_iso8601(cell_value, locale) end defp format_cell_date(cell_value, _locale), do: cell_value defp format_cell_date_iso8601(cell_value, locale) do case Date.from_iso8601(cell_value) do {:ok, date} -> format_date(date, locale) _ -> format_cell_date_datetime(cell_value, locale) end end defp format_cell_date_datetime(cell_value, locale) do case DateTime.from_iso8601(cell_value) do {:ok, datetime} -> format_datetime(datetime, locale) _ -> format_cell_date_naive(cell_value, locale) end end defp format_cell_date_naive(cell_value, locale) do case NaiveDateTime.from_iso8601(cell_value) do {:ok, naive_dt} -> datetime = DateTime.from_naive!(naive_dt, "Etc/UTC") format_datetime(datetime, locale) _ -> cell_value end end defp translate_membership_fee_status_in_rows(rows, columns) do status_col_index = find_membership_fee_status_index(columns) if status_col_index do translate_rows_status(rows, status_col_index) else rows end end defp find_membership_fee_status_index(columns) do Enum.find_index(columns, fn col -> col.kind == :computed && col.key == :membership_fee_status end) end defp translate_rows_status(rows, status_col_index) do Enum.map(rows, fn row -> List.update_at(row, status_col_index, &translate_membership_fee_status/1) end) end defp translate_membership_fee_status("paid"), do: gettext("Paid") defp translate_membership_fee_status("unpaid"), do: gettext("Unpaid") defp translate_membership_fee_status("suspended"), do: gettext("Suspended") defp translate_membership_fee_status(value), do: value defp build_imprintor_config(template_content, template_data, root_directory) do # Imprintor.Config.new(source_document, inputs, options) # inputs: %{"elixir_data" => template_data} for sys.inputs.elixir_data in template # options: set root_directory to temp dir to ensure no symlink issues # extra_fonts: list of font file paths for Typst to use extra_fonts = get_extra_fonts() options = [root_directory: root_directory, extra_fonts: extra_fonts] config = Imprintor.Config.new(template_content, template_data, options) {:ok, config} end defp get_extra_fonts do font_paths = get_font_paths() Enum.reduce_while(font_paths, [], &find_fonts_in_path/2) |> normalize_fonts_result() end defp get_font_paths do [ Path.join(Application.app_dir(:mv, "priv"), "fonts"), Path.join([File.cwd!(), "priv", "fonts"]) ] end defp find_fonts_in_path(base_path, _acc) do case File.ls(base_path) do {:ok, files} -> process_font_files(files, base_path) {:error, _reason} -> {:cont, []} end end defp process_font_files(files, base_path) do fonts = files |> Enum.filter(&String.ends_with?(&1, ".ttf")) |> Enum.map(&Path.join(base_path, &1)) |> Enum.sort() if fonts != [] do {:halt, fonts} else {:cont, []} end end defp normalize_fonts_result([]), do: [] defp normalize_fonts_result(fonts), do: fonts defp compile_to_pdf(config) do case Imprintor.compile_to_pdf(config) do {:ok, pdf_binary} when is_binary(pdf_binary) -> # Verify PDF magic bytes if String.starts_with?(pdf_binary, "%PDF") do {:ok, pdf_binary} else Logger.error( "PDF compilation returned invalid format (start: #{String.slice(pdf_binary, 0, 20)})" ) {:error, :invalid_pdf_format} end {:error, reason} -> Logger.error("PDF compilation failed", error: inspect(reason), error_type: :imprintor_compile_error ) {:error, {:compile_error, reason}} other -> Logger.error("PDF compilation returned unexpected result: #{inspect(other)}", error_type: :unexpected_result ) {:error, {:unexpected_result, other}} end rescue e -> Logger.error("PDF compilation raised exception: #{inspect(e)}", error_type: :compile_exception ) {:error, {:compile_exception, e}} end end