diff --git a/config/config.exs b/config/config.exs
index 6720a5d..d4de2c2 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -58,6 +58,12 @@ config :mv,
max_rows: 1000
]
+# PDF Export configuration
+config :mv,
+ pdf_export: [
+ row_limit: 5000
+ ]
+
# OIDC group → role sync (optional). Overridden in runtime.exs from ENV in production.
config :mv, :oidc_role_sync,
admin_group_name: nil,
diff --git a/docs/pdf-generation-imprintor.md b/docs/pdf-generation-imprintor.md
new file mode 100644
index 0000000..f8ce2ee
--- /dev/null
+++ b/docs/pdf-generation-imprintor.md
@@ -0,0 +1,71 @@
+# PDF Generation: Imprintor statt Chromium
+
+## Übersicht
+
+Für die PDF-Generierung in der Mitgliederverwaltung verwenden wir **Imprintor** (`~> 0.5.0`) anstelle von Chromium-basierten Lösungen (wie z.B. Puppeteer, Chrome Headless, oder ähnliche).
+
+## Warum Imprintor statt Chromium?
+
+### 1. Ressourceneffizienz
+
+- **Geringerer Speicherverbrauch**: Imprintor benötigt keine vollständige Browser-Instanz im Speicher
+- **Niedrigere CPU-Last**: Native PDF-Generierung ohne Browser-Rendering-Pipeline
+- **Kleinere Docker-Images**: Keine Chromium-Installation erforderlich (spart mehrere hundert MB)
+
+### 2. Performance
+
+- **Schnellere Generierung**: Direkte PDF-Generierung ohne HTML-Rendering-Overhead
+- **Bessere Skalierbarkeit**: Kann mehrere PDFs parallel generieren ohne Browser-Instanzen zu verwalten
+- **Niedrigere Latenz**: Keine Browser-Startup-Zeit
+
+### 3. Deployment & Wartung
+
+- **Einfacheres Deployment**: Keine System-Abhängigkeiten (Chromium, ChromeDriver, etc.)
+- **Weniger Wartungsaufwand**: Keine Browser-Version-Updates zu verwalten
+- **Bessere Container-Kompatibilität**: Funktioniert in minimalen Docker-Images (z.B. Alpine)
+
+### 4. Sicherheit
+
+- **Kleinere Angriffsfläche**: Keine Browser-Engine mit bekannten Sicherheitslücken
+- **Isolation**: Weniger System-Calls und externe Prozesse
+
+### 5. Elixir-Native Lösung
+
+- **Erlang/OTP-Integration**: Nutzt die Vorteile der BEAM-VM (Concurrency, Fault Tolerance)
+- **Type-Safety**: Bessere Integration mit Elixir-Typen und Pattern Matching
+- **Einfachere Fehlerbehandlung**: Elixir-native Error-Handling statt externer Prozesse
+
+## Wann Chromium trotzdem sinnvoll wäre
+
+Chromium-basierte Lösungen sind sinnvoll, wenn:
+- Komplexe JavaScript-Ausführung im HTML nötig ist
+- Moderne CSS-Features (Grid, Flexbox, etc.) kritisch sind
+- Screenshots von Web-Seiten generiert werden sollen
+- Dynamische Inhalte gerendert werden müssen, die JavaScript erfordern
+
+## Verwendung in diesem Projekt
+
+Imprintor wird für folgende Anwendungsfälle verwendet:
+- **Member-Export als PDF**: Generierung von Mitgliederlisten und -reports
+- **Statische Reports**: PDF-Generierung für vordefinierte Report-Formate
+- **Dokumente**: Generierung von Mitgliedschaftsbescheinigungen, Rechnungen, etc.
+
+## Technische Details
+
+- **Dependency**: `{:imprintor, "~> 0.5.0"}`
+- **Typ**: Native Elixir-Bibliothek (vermutlich basierend auf Rust-NIFs oder ähnlichen Technologien)
+- **Format**: Generiert PDF direkt aus HTML/Templates ohne Browser-Engine
+
+## Migration von Chromium (falls vorhanden)
+
+Falls zuvor eine Chromium-basierte Lösung verwendet wurde:
+1. HTML-Templates müssen ggf. angepasst werden (kein JavaScript-Support)
+2. CSS muss statisch sein (keine dynamischen Styles)
+3. Komplexe Layouts sollten vorher getestet werden
+
+## Weitere Ressourcen
+
+- [Imprintor auf Hex.pm](https://hex.pm/packages/imprintor)
+- [GitHub Repository](https://github.com/[imprintor-repo]) (falls verfügbar)
+
+
diff --git a/lib/mv/config.ex b/lib/mv/config.ex
index 007309a..bcbc8d9 100644
--- a/lib/mv/config.ex
+++ b/lib/mv/config.ex
@@ -116,4 +116,30 @@ defmodule Mv.Config do
defp parse_and_validate_integer(_value, default) do
default
end
+
+ @doc """
+ Returns the maximum number of rows allowed in PDF exports.
+
+ Reads the `row_limit` value from the PDF export configuration.
+
+ ## Returns
+
+ - Maximum number of rows (default: 5000)
+
+ ## Examples
+
+ iex> Mv.Config.pdf_export_row_limit()
+ 5000
+ """
+ @spec pdf_export_row_limit() :: pos_integer()
+ def pdf_export_row_limit do
+ get_pdf_export_config(:row_limit, 5000)
+ end
+
+ # Helper function to get PDF export config values
+ defp get_pdf_export_config(key, default) do
+ Application.get_env(:mv, :pdf_export, [])
+ |> Keyword.get(key, default)
+ |> parse_and_validate_integer(default)
+ end
end
diff --git a/lib/mv/membership/member_export/build.ex b/lib/mv/membership/member_export/build.ex
new file mode 100644
index 0000000..ade4d82
--- /dev/null
+++ b/lib/mv/membership/member_export/build.ex
@@ -0,0 +1,433 @@
+defmodule Mv.Membership.MemberExport.Build do
+ @moduledoc """
+ Builds export data structure for member exports (CSV/PDF).
+
+ Extracts common logic for loading, filtering, sorting, and formatting member data
+ into a unified structure that can be used by both CSV and PDF exporters.
+
+ Returns a structure:
+ ```
+ %{
+ columns: [%{key: term(), kind: :member_field | :custom_field | :computed, ...}],
+ rows: [[cell_string, ...]],
+ meta: %{generated_at: String.t(), member_count: integer(), ...}
+ }
+ ```
+
+ No translations/Gettext in this module - labels come from the web layer via a function.
+ """
+
+ require Ash.Query
+ import Ash.Expr
+
+ alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
+ alias MvWeb.MemberLive.Index.MembershipFeeStatus
+
+ @custom_field_prefix Mv.Constants.custom_field_prefix()
+
+ @doc """
+ Builds export data structure from parsed parameters.
+
+ - `actor` - Ash actor (e.g. current user)
+ - `parsed` - Map with export parameters (from `MemberExport.parse_params/1`)
+ - `label_fn` - Function to get labels for columns: `(key) -> String.t()`
+
+ Returns `{:ok, data}` or `{:error, :forbidden}`.
+
+ The `data` map contains:
+ - `columns`: List of column specs with `key`, `kind`, and optional `custom_field`
+ - `rows`: List of rows, each row is a list of cell strings
+ - `meta`: Metadata including `generated_at` and `member_count`
+ """
+ @spec build(struct(), map(), (term() -> String.t())) ::
+ {:ok, map()} | {:error, :forbidden}
+ def build(actor, parsed, label_fn) when is_function(label_fn, 1) do
+ # Ensure sort custom field is loaded if needed
+ parsed = ensure_sort_custom_field_loaded(parsed)
+
+ custom_field_ids_union =
+ (parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
+
+ with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
+ {:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
+ columns = build_columns(parsed, custom_fields_by_id, label_fn)
+ rows = build_rows(members, columns, custom_fields_by_id)
+ meta = build_meta(members)
+
+ {:ok, %{columns: columns, rows: rows, meta: meta}}
+ end
+ end
+
+ defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
+ case extract_sort_custom_field_id(sort_field) do
+ nil -> parsed
+ id -> %{parsed | custom_field_ids: Enum.uniq([id | ids])}
+ end
+ end
+
+ defp extract_sort_custom_field_id(field) when is_binary(field) do
+ if String.starts_with?(field, @custom_field_prefix) do
+ String.trim_leading(field, @custom_field_prefix)
+ else
+ nil
+ end
+ end
+
+ defp extract_sort_custom_field_id(_), do: nil
+
+ defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
+
+ defp load_custom_fields_by_id(custom_field_ids, actor) do
+ query =
+ CustomField
+ |> Ash.Query.filter(expr(id in ^custom_field_ids))
+ |> Ash.Query.select([:id, :name, :value_type])
+
+ case Ash.read(query, actor: actor) do
+ {:ok, custom_fields} ->
+ by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
+ {:ok, by_id}
+
+ {:error, %Ash.Error.Forbidden{}} ->
+ {:error, :forbidden}
+ end
+ end
+
+ defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
+ Enum.reduce(custom_field_ids, %{}, fn id, acc ->
+ find_and_add_custom_field(acc, id, custom_fields)
+ end)
+ end
+
+ defp find_and_add_custom_field(acc, id, custom_fields) do
+ case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
+ nil -> acc
+ cf -> Map.put(acc, id, cf)
+ end
+ end
+
+ defp load_members(actor, parsed, custom_fields_by_id) do
+ {query, sort_after_load} = build_members_query(parsed, custom_fields_by_id)
+
+ case Ash.read(query, actor: actor) do
+ {:ok, members} ->
+ processed_members =
+ process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load)
+
+ {:ok, processed_members}
+
+ {:error, %Ash.Error.Forbidden{}} ->
+ {:error, :forbidden}
+ end
+ end
+
+ defp build_members_query(parsed, _custom_fields_by_id) do
+ select_fields =
+ [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
+
+ custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
+
+ need_cycles =
+ parsed.show_current_cycle or parsed.cycle_status_filter != nil or
+ parsed.computed_fields != [] or
+ "membership_fee_status" in parsed.member_fields
+
+ query =
+ Member
+ |> Ash.Query.new()
+ |> Ash.Query.select(select_fields)
+ |> load_custom_field_values_query(custom_field_ids_union)
+ |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
+
+ query =
+ if parsed.selected_ids != [] do
+ Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
+ else
+ apply_search(query, parsed.query)
+ end
+
+ # Apply sorting at query level if possible (not custom fields)
+ maybe_sort(query, parsed.sort_field, parsed.sort_order)
+ end
+
+ defp process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load) do
+ members
+ |> apply_post_load_filters(parsed, custom_fields_by_id)
+ |> apply_post_load_sorting(parsed, custom_fields_by_id, sort_after_load)
+ |> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
+ end
+
+ defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
+ if parsed.selected_ids == [] do
+ members
+ |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
+ |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
+ parsed.boolean_filters || %{},
+ Map.values(custom_fields_by_id)
+ )
+ else
+ members
+ end
+ end
+
+ defp apply_post_load_sorting(members, parsed, custom_fields_by_id, sort_after_load) do
+ # Sort after load for custom fields (always, even with selected_ids)
+ if sort_after_load do
+ sort_members_by_custom_field(
+ members,
+ parsed.sort_field,
+ parsed.sort_order,
+ Map.values(custom_fields_by_id)
+ )
+ else
+ # For selected_ids, we may need to apply sorting that wasn't done at query level
+ if (parsed.selected_ids != [] and parsed.sort_field) && parsed.sort_order do
+ # Re-sort in memory to ensure consistent ordering
+ sort_members_in_memory(members, parsed.sort_field, parsed.sort_order)
+ else
+ members
+ end
+ end
+ end
+
+ defp sort_members_in_memory(members, field, order) when is_binary(field) do
+ field_atom = String.to_existing_atom(field)
+
+ if field_atom in Mv.Constants.member_fields() do
+ Enum.sort_by(members, fn member -> Map.get(member, field_atom) end, fn a, b ->
+ case order do
+ "asc" -> a <= b
+ "desc" -> b <= a
+ _ -> true
+ end
+ end)
+ else
+ members
+ end
+ rescue
+ ArgumentError -> members
+ end
+
+ defp sort_members_in_memory(members, _field, _order), do: members
+
+ defp load_custom_field_values_query(query, []), do: query
+
+ defp load_custom_field_values_query(query, custom_field_ids) do
+ cfv_query =
+ Mv.Membership.CustomFieldValue
+ |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
+ |> Ash.Query.load(custom_field: [:id, :name, :value_type])
+
+ Ash.Query.load(query, custom_field_values: cfv_query)
+ end
+
+ defp apply_search(query, nil), do: query
+ defp apply_search(query, ""), do: query
+
+ defp apply_search(query, q) when is_binary(q) do
+ if String.trim(q) != "" do
+ Member.fuzzy_search(query, %{query: q})
+ else
+ query
+ end
+ end
+
+ defp maybe_sort(query, nil, _order), do: {query, false}
+ defp maybe_sort(query, _field, nil), do: {query, false}
+
+ defp maybe_sort(query, field, order) when is_binary(field) do
+ if custom_field_sort?(field) do
+ {query, true}
+ else
+ field_atom = String.to_existing_atom(field)
+
+ if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
+ {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
+ else
+ {query, false}
+ end
+ end
+ rescue
+ ArgumentError -> {query, false}
+ end
+
+ defp sort_after_load?(field) when is_binary(field),
+ do: String.starts_with?(field, @custom_field_prefix)
+
+ defp sort_after_load?(_), do: false
+
+ defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
+ do: []
+
+ defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
+ id_str = String.trim_leading(field, @custom_field_prefix)
+ custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
+
+ if is_nil(custom_field), do: members
+
+ key_fn = fn member ->
+ cfv = find_cfv(member, custom_field)
+ raw = if cfv, do: cfv.value, else: nil
+ MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
+ end
+
+ members
+ |> Enum.map(fn m -> {m, key_fn.(m)} end)
+ |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
+ |> Enum.map(fn {m, _} -> m end)
+ end
+
+ defp find_cfv(member, custom_field) do
+ (member.custom_field_values || [])
+ |> Enum.find(fn cfv ->
+ to_string(cfv.custom_field_id) == to_string(custom_field.id) or
+ (Map.get(cfv, :custom_field) &&
+ to_string(cfv.custom_field.id) == to_string(custom_field.id))
+ end)
+ end
+
+ defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
+
+ defp maybe_load_cycles(query, false, _show_current), do: query
+
+ defp maybe_load_cycles(query, true, show_current) do
+ MembershipFeeStatus.load_cycles_for_members(query, show_current)
+ end
+
+ defp apply_cycle_status_filter(members, nil, _show_current), do: members
+
+ defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
+ MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
+ end
+
+ defp apply_cycle_status_filter(members, _status, _show_current), do: members
+
+ defp add_computed_fields(members, computed_fields, show_current_cycle) do
+ computed_fields = computed_fields || []
+
+ if "membership_fee_status" in computed_fields do
+ Enum.map(members, fn member ->
+ status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
+ # Format as string for export (controller will handle translation)
+ status_string = format_membership_fee_status(status)
+ Map.put(member, :membership_fee_status, status_string)
+ end)
+ else
+ members
+ end
+ end
+
+ defp format_membership_fee_status(:paid), do: "paid"
+ defp format_membership_fee_status(:unpaid), do: "unpaid"
+ defp format_membership_fee_status(:suspended), do: "suspended"
+ defp format_membership_fee_status(nil), do: ""
+
+ defp build_columns(parsed, custom_fields_by_id, label_fn) do
+ member_cols =
+ Enum.map(parsed.selectable_member_fields, fn field ->
+ %{
+ key: field,
+ kind: :member_field,
+ label: label_fn.(field)
+ }
+ end)
+
+ computed_cols =
+ Enum.map(parsed.computed_fields, fn key ->
+ atom_key = String.to_existing_atom(key)
+
+ %{
+ key: atom_key,
+ kind: :computed,
+ label: label_fn.(atom_key)
+ }
+ end)
+
+ custom_cols =
+ parsed.custom_field_ids
+ |> Enum.map(fn id ->
+ cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))
+
+ if cf do
+ %{
+ key: to_string(id),
+ kind: :custom_field,
+ label: label_fn.(id),
+ custom_field: cf
+ }
+ else
+ nil
+ end
+ end)
+ |> Enum.reject(&is_nil/1)
+
+ member_cols ++ computed_cols ++ custom_cols
+ end
+
+ defp build_rows(members, columns, custom_fields_by_id) do
+ Enum.map(members, fn member ->
+ Enum.map(columns, fn col -> cell_value(member, col, custom_fields_by_id) end)
+ end)
+ end
+
+ defp cell_value(member, %{kind: :member_field, key: key}, _custom_fields_by_id) do
+ key_atom = key_to_atom(key)
+ value = Map.get(member, key_atom)
+ format_member_value(value)
+ end
+
+ defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}, _custom_fields_by_id) do
+ cfv = get_cfv_by_id(member, id)
+
+ if cfv do
+ CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf)
+ else
+ ""
+ end
+ end
+
+ defp cell_value(member, %{kind: :computed, key: key}, _custom_fields_by_id) do
+ value = Map.get(member, key)
+ if is_binary(value), do: value, else: ""
+ end
+
+ defp key_to_atom(k) when is_atom(k), do: k
+
+ defp key_to_atom(k) when is_binary(k) do
+ try do
+ String.to_existing_atom(k)
+ rescue
+ ArgumentError -> k
+ end
+ end
+
+ defp get_cfv_by_id(member, id) do
+ values =
+ case Map.get(member, :custom_field_values) do
+ v when is_list(v) -> v
+ _ -> []
+ end
+
+ id_str = to_string(id)
+
+ Enum.find(values, fn cfv ->
+ to_string(cfv.custom_field_id) == id_str or
+ (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
+ end)
+ end
+
+ defp format_member_value(nil), do: ""
+ defp format_member_value(true), do: "true"
+ defp format_member_value(false), do: "false"
+ defp format_member_value(%Date{} = d), do: Date.to_iso8601(d)
+ defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
+ defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
+ defp format_member_value(value), do: to_string(value)
+
+ defp build_meta(members) do
+ %{
+ generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
+ member_count: length(members)
+ }
+ end
+end
diff --git a/lib/mv/membership/members_pdf.ex b/lib/mv/membership/members_pdf.ex
new file mode 100644
index 0000000..690da74
--- /dev/null
+++ b/lib/mv/membership/members_pdf.ex
@@ -0,0 +1,270 @@
+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. No translations/Gettext
+ in this module - labels come from the web layer.
+
+ 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
+
+ alias Mv.Config
+
+ @template_filename "members_export.typ"
+ @template_path "priv/pdf_templates/members_export.typ"
+
+ @doc """
+ Renders export data to PDF binary.
+
+ - `export_data` - Map from `MemberExport.Build.build/3` with `columns`, `rows`, and `meta`
+
+ 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()) :: {:ok, binary()} | {:error, term()}
+ def render(export_data) 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",
+ row_count: row_count,
+ max_rows: max_rows,
+ error_type: :row_limit_exceeded
+ )
+
+ {:error, {:row_limit_exceeded, row_count, max_rows}}
+ else
+ Logger.info("Starting PDF export",
+ row_count: row_count,
+ column_count: length(export_data.columns)
+ )
+
+ create_and_use_temp_directory(export_data)
+ end
+ end
+
+ defp create_and_use_temp_directory(export_data) 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),
+ {: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",
+ row_count: length(export_data.rows)
+ )
+
+ {:ok, pdf_binary}
+ else
+ {:error, reason} = error ->
+ Logger.error("PDF export failed",
+ error: 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",
+ error: 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: temp_dir,
+ error: reason
+ )
+ end
+ end
+
+ defp convert_to_template_format(export_data) do
+ 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 =
+ Map.get(meta, :generated_at) ||
+ Map.get(meta, "generated_at") ||
+ DateTime.utc_now() |> DateTime.to_iso8601()
+
+ member_count =
+ Map.get(meta, :member_count) ||
+ Map.get(meta, "member_count") ||
+ length(export_data.rows)
+
+ template_data = %{
+ "title" => "Mitglieder-Export",
+ "generated_at" => generated_at,
+ "column_count" => column_count,
+ "headers" => headers,
+ "rows" => export_data.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
+ }
+ }
+
+ {:ok, template_data}
+ end
+
+ 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
+ # Try multiple paths: compiled app path and source path (for tests/dev)
+ font_paths = [
+ Path.join(Application.app_dir(:mv, "priv"), "fonts"),
+ Path.join([File.cwd!(), "priv", "fonts"])
+ ]
+
+ Enum.reduce_while(font_paths, [], fn base_path, _acc ->
+ case File.ls(base_path) do
+ {:ok, files} ->
+ 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
+
+ {:error, _reason} ->
+ {:cont, []}
+ end
+ end)
+ |> case do
+ [] -> []
+ fonts -> fonts
+ end
+ end
+
+ 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",
+ binary_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",
+ result: inspect(other),
+ error_type: :unexpected_result
+ )
+
+ {:error, {:unexpected_result, other}}
+ end
+ rescue
+ e ->
+ Logger.error("PDF compilation raised exception",
+ exception: inspect(e),
+ error_type: :compile_exception
+ )
+
+ {:error, {:compile_exception, e}}
+ end
+end
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 60f3636..b5b3942 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -151,9 +151,17 @@ defmodule MvWeb.CoreComponents do
## Examples
<.dropdown_menu items={@items} open={@open} phx_target={@myself} />
+
+ When using custom content (e.g., forms), use the inner_block slot:
+
+ <.dropdown_menu button_label="Export" icon="hero-arrow-down-tray" open={@open} phx_target={@myself}>
+
+
+
+
"""
attr :id, :string, default: "dropdown-menu"
- attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
+ attr :items, :list, default: [], doc: "List of %{label: string, value: any} maps"
attr :button_label, :string, default: "Dropdown"
attr :icon, :string, default: nil
attr :checkboxes, :boolean, default: false
@@ -161,8 +169,20 @@ defmodule MvWeb.CoreComponents do
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
+ attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
+ attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
+ attr :button_class, :string, default: nil, doc: "Additional CSS classes for the button (e.g., btn-secondary)"
+ attr :menu_align, :string, default: "right", doc: "Menu alignment: 'left' or 'right' (default: right)"
+ attr :testid, :string, default: "dropdown-menu", doc: "data-testid for the dropdown container"
+ attr :button_testid, :string, default: "dropdown-button", doc: "data-testid for the button"
+ attr :menu_testid, :string, default: nil, doc: "data-testid for the menu (defaults to testid + '-menu')"
+
+ slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
def dropdown_menu(assigns) do
+ menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
+ assigns = assign(assigns, :menu_testid, menu_testid)
+
~H"""